Cross-Service Integration Tests
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Test infrastructure and RabbitMQ event verification helpers |
Not started |
2 |
Workflow 1: Referral → Investigation → Safety Assessment → Case Creation |
Not started |
3 |
Workflow 2: Case → Placement → Foster Home Assignment → Payment Calculation |
Not started |
4 |
Workflow 3: Public Report → Claim → Screen → Convert to Referral (cross-service: intake → cases) |
Not started |
5 |
Workflow 4: Exchange Partner → Agreement → Activate → Send Transaction |
Not started |
Epic: &TBD
Issues: #TBD
Branch: feature/cross-service-integration-tests
Context
CRAIG’s integration tests currently verify individual service endpoints in isolation. Each service has its own test suite that exercises CRUD operations and business logic within a single service boundary. However, the critical user workflows span multiple services:
-
A referral flows from craig-cases through craig-rules (safety assessment) back to craig-cases (case creation)
-
Placement decisions in craig-placement reference cases/persons created in craig-cases, then payment calculations in craig-financial reference placement/case/child IDs
-
Public reports submitted to craig-intake are converted to referrals in craig-cases
-
Exchange transactions flow through partner agreements, adapters, and state machines in craig-exchange
These cross-service paths have no automated testing today. Bugs in inter-service communication (HTTP calls, RabbitMQ events, state synchronization) are only caught by E2E Playwright tests or manual testing, both of which are slow and brittle.
This plan adds Rust integration tests that exercise complete multi-service workflows using the existing TestHarness and typed ServiceClient infrastructure from craig-test-lib.
Scope
In scope:
-
4 cross-service workflow tests exercising critical business paths
-
RabbitMQ event verification using the existing
EventCollector -
Sequential multi-service HTTP calls with state verification at each step
-
Tests run against the devstack (same as existing integration tests)
Out of scope:
-
Performance characteristics of cross-service calls (covered by performance testing plan)
-
Negative path testing (invalid state transitions across services)
-
Data consistency verification (eventual consistency edge cases)
Design
Test Infrastructure — TestHarness Client Factories
The TestHarness struct in crates/craig-test-lib/src/harness.rs provides:
-
config: TestConfig— base URLs for all 9 services, RabbitMQ URL, Keycloak URL -
tokens: KeycloakTokenProvider— OIDC token acquisition -
rand_name(prefix)— unique name generator using UUID v7 -
register_cleanup(f)/register_delete(base_url, token, path)— LIFO cleanup stack -
cleanup()— teardown in LIFO order -
event_collector(routing_keys)— creates anEventCollectorbound to the given routing keys
Complete Client Factory List
All factories are async and return Result<XxxClient>. Each client wraps ServiceClient which provides get, post, put, delete, get_with_query, post_multipart, get_bytes, get_raw, and delete_with_body.
| Role | Service | Method |
|---|---|---|
admin |
Rules |
|
admin |
Cases |
|
admin |
Placement |
|
admin |
Exchange |
|
admin |
Financial |
|
admin |
Reporting |
|
admin |
Security |
|
admin |
Intake |
|
supervisor (jane.doe) |
Rules |
|
supervisor (jane.doe) |
Cases |
|
supervisor (jane.doe) |
Placement |
|
supervisor (jane.doe) |
Exchange |
|
supervisor (jane.doe) |
Financial |
|
supervisor (jane.doe) |
Reporting |
|
supervisor (jane.doe) |
Security |
|
supervisor (jane.doe) |
Intake |
|
caseworker (bob.smith) |
Rules |
|
caseworker (bob.smith) |
Cases |
|
caseworker (bob.smith) |
Placement |
|
caseworker (bob.smith) |
Exchange |
|
caseworker (bob.smith) |
Financial |
|
caseworker (bob.smith) |
Reporting |
|
caseworker (bob.smith) |
Security |
|
caseworker (bob.smith) |
Intake |
|
Test Users (pinned in devstack/keycloak/craig-realm.json)
| Const | Username | Password | sub (UUID) |
|---|---|---|---|
|
admin |
password |
|
|
jane.doe |
password |
|
|
bob.smith |
password |
|
Role Requirements Per Endpoint
-
create_partner,create_agreement,update_agreement,create_rate: admin -
send_exchange,approve_payment: supervisor -
create_foster_home: supervisor (require_supervisor_or_above) -
create_referral,create_investigation,create_case,create_placement,calculate_payment: caseworker -
submit_public_report: unauthenticated (public endpoint) -
claim_report,convert_report: caseworker
EventCollector API (Already Implemented)
The EventCollector lives in crates/craig-test-lib/src/events.rs and is fully implemented.
Constructor:
// Create via TestHarness helper:
let events = harness.event_collector(&["case.#", "placement.#"]).await?;
// Or directly:
let events = EventCollector::new(&config.rabbitmq_url, &["case.#"]).await?;
Methods:
| Method | Description |
|---|---|
|
Creates exclusive auto-delete queue bound to |
|
Polls internal buffer every 100ms until an event with matching |
|
Panics if no event with matching |
|
Panics if the event’s |
|
Returns all collected events (non-destructive read). |
Event routing keys published by each service:
| Service | Routing Keys |
|---|---|
craig-cases |
|
craig-placement |
|
craig-exchange |
|
craig-financial |
|
craig-intake |
(none — stateless edge per ADR-017; report lifecycle events live on craig-cases as |
Test File Location
tests/integration/
workflows.rs # All 4 workflow tests
This file lives under the workspace-level tests/ directory (not inside a specific service crate) because it depends on multiple service clients.
Alternatively, if the workspace-level test directory is not configured for integration tests, place the file in crates/craig-test-lib/tests/workflows.rs since craig-test-lib already depends on all service client types.
Builders Available in craig_test_lib::builders
| Builder | Key Defaults |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Steps
Step 1: Test Infrastructure Verification
Files: tests/integration/workflows.rs (NEW), Cargo.toml (if needed)
Create the test file with shared helper functions and verify event infrastructure works:
// tests/integration/workflows.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use craig_test_lib::builders::{
AgreementBuilder, ExchangePartnerBuilder, FosterHomeBuilder, PersonBuilder, PlacementBuilder,
ReferralBuilder,
};
use craig_test_lib::{StatusCode, TestHarness, devstack_available};
use serde_json::json;
use std::time::Duration;
// All tests below use this pattern:
// if !devstack_available().await { return; }
// let harness = TestHarness::new().await.unwrap();
Step 2: Workflow 1 — Referral to Investigation to Case Creation
Services touched: craig-cases, craig-rules (via safety assessment)
Flow: Create persons → Create referral → Add allegation → Open investigation → Submit safety assessment (calls craig-rules) → Close investigation → Create case → Verify events
#[tokio::test]
async fn workflow_referral_to_case() {
if !devstack_available().await {
return;
}
let harness = TestHarness::new().await.unwrap();
// Set up event collector before actions so we capture all events.
let events = harness
.event_collector(&["case.#"])
.await
.unwrap();
let caseworker = harness.caseworker_cases_client().await.unwrap();
let supervisor = harness.supervisor_cases_client().await.unwrap();
// 1. Create child person
let child_resp = caseworker
.create_person(
&PersonBuilder::new()
.first_name("Workflow")
.last_name("Child")
.date_of_birth("2018-03-15")
.build(),
)
.await
.unwrap();
assert_eq!(child_resp.status, StatusCode::OK);
let child_id = child_resp.body.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// 2. Create parent person
let parent_resp = caseworker
.create_person(
&PersonBuilder::new()
.first_name("Workflow")
.last_name("Parent")
.date_of_birth("1985-07-20")
.build(),
)
.await
.unwrap();
assert_eq!(parent_resp.status, StatusCode::OK);
let parent_id = parent_resp.body.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// 3. Create referral
let referral_resp = caseworker
.create_referral(
&ReferralBuilder::new()
.priority("24_hour")
.admin_unit("Fulton")
.build(),
)
.await
.unwrap();
assert_eq!(referral_resp.status, StatusCode::OK);
let referral_body = referral_resp.body.unwrap();
let referral_id = referral_body["id"].as_str().unwrap().to_string();
// 4. Add allegation linking child victim and parent perpetrator
let allegation_resp = caseworker
.create_allegation(
&referral_id,
&json!({
"victim_id": child_id,
"perpetrator_id": parent_id,
"abuse_type": "neglect",
"description": "Cross-service workflow test allegation"
}),
)
.await
.unwrap();
assert_eq!(allegation_resp.status, StatusCode::OK);
// 5. Open investigation from referral
let inv_resp = caseworker
.create_investigation(&json!({
"referral_id": referral_id,
"assigned_worker": "bob.smith",
"assignment_area": "Fulton",
"response_due_at": "2026-04-15T00:00:00Z"
}))
.await
.unwrap();
assert_eq!(inv_resp.status, StatusCode::OK);
let inv_id = inv_resp.body.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// 6. Submit safety assessment (cross-service call: cases -> rules engine)
let assessment_resp = caseworker
.submit_safety_assessment(
&inv_id,
&json!({
"threats": {
"caregiver_substance_abuse": true,
"domestic_violence": false,
"inadequate_supervision": true,
"physical_harm": false
},
"protective_capacities": {
"family_support_system": true,
"caregiver_cooperation": true,
"stable_housing": false
}
}),
)
.await
.unwrap();
// Safety assessment may return OK or an error if rules engine ruleset is not loaded;
// the key assertion is that the cross-service call was attempted.
// In a fully seeded devstack, this should succeed:
assert_eq!(assessment_resp.status, StatusCode::OK);
// 7. Close investigation with disposition
let close_resp = caseworker
.update_investigation(
&inv_id,
&json!({
"status": "closed",
"disposition": "substantiated"
}),
)
.await
.unwrap();
assert_eq!(close_resp.status, StatusCode::OK);
// 8. Create case from the investigation
let case_resp = caseworker
.create_case(&json!({
"investigation_id": inv_id,
"admin_unit": "Fulton",
"assigned_worker": "bob.smith",
"icwa_flag": false
}))
.await
.unwrap();
assert_eq!(case_resp.status, StatusCode::OK);
let case_body = case_resp.body.unwrap();
let case_id = case_body["id"].as_str().unwrap().to_string();
assert_eq!(case_body["status"].as_str().unwrap(), "open");
assert!(
case_body["case_number"].as_str().is_some(),
"case_number should be generated"
);
// 9. Add child to household
let hh_resp = caseworker
.add_household_member(
&case_id,
&json!({
"person_id": child_id,
"role": "child",
"primary_caregiver": false
}),
)
.await
.unwrap();
assert_eq!(hh_resp.status, StatusCode::OK);
// 10. Create case plan and have supervisor approve it
let plan_resp = caseworker
.create_case_plan(
&case_id,
&json!({"permanency_goal": "reunification"}),
)
.await
.unwrap();
assert_eq!(plan_resp.status, StatusCode::OK);
let plan_id = plan_resp.body.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let approve_resp = supervisor.approve_case_plan(&plan_id).await.unwrap();
assert_eq!(approve_resp.status, StatusCode::OK);
// 11. Verify events were published (allow 5s for async delivery)
let _ = events
.wait_for_event("case.referral_created", Duration::from_secs(5))
.await;
events.assert_received("case.referral_created").await;
events.assert_received("case.intake_created").await;
events.assert_received("case.investigation_closed").await;
events.assert_received("case.created").await;
events.assert_received("case.plan_created").await;
}
Step 3: Workflow 2 — Case to Placement to Payment Calculation
Services touched: craig-cases, craig-placement, craig-financial
Flow: Create case prerequisites → Create foster home → Create placement → Set up rate table → Calculate payment → Verify cross-service references and events
#[tokio::test]
async fn workflow_case_to_placement_to_payment() {
if !devstack_available().await {
return;
}
let harness = TestHarness::new().await.unwrap();
let events = harness
.event_collector(&["case.#", "placement.#"])
.await
.unwrap();
let caseworker_cases = harness.caseworker_cases_client().await.unwrap();
let supervisor_placement = harness.supervisor_placement_client().await.unwrap();
let caseworker_placement = harness.caseworker_placement_client().await.unwrap();
let admin_financial = harness.admin_financial_client().await.unwrap();
let caseworker_financial = harness.caseworker_financial_client().await.unwrap();
// 1. Create a child person in craig-cases
let child_resp = caseworker_cases
.create_person(
&PersonBuilder::new()
.first_name("Placement")
.last_name("Child")
.date_of_birth("2016-05-10")
.build(),
)
.await
.unwrap();
assert_eq!(child_resp.status, StatusCode::OK);
let child_id = child_resp.body.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// 2. Create a case in craig-cases (direct filing, no investigation)
let case_resp = caseworker_cases
.create_case(&json!({
"admin_unit": "Fulton",
"assigned_worker": "bob.smith",
"icwa_flag": false
}))
.await
.unwrap();
assert_eq!(case_resp.status, StatusCode::OK);
let case_id = case_resp.body.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// 3. Add child to case household
let hh_resp = caseworker_cases
.add_household_member(
&case_id,
&json!({
"person_id": child_id,
"role": "child",
"primary_caregiver": false
}),
)
.await
.unwrap();
assert_eq!(hh_resp.status, StatusCode::OK);
// 4. Create a foster home in craig-placement (requires supervisor)
let home_resp = supervisor_placement
.create_foster_home(
&FosterHomeBuilder::new()
.name(harness.rand_name("Workflow Home"))
.admin_unit("Fulton")
.license_type("regular")
.max_capacity(4)
.build(),
)
.await
.unwrap();
assert_eq!(home_resp.status, StatusCode::OK);
let home_id = home_resp.body.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// 5. Create placement linking case/child to foster home in craig-placement
let placement_resp = caseworker_placement
.create_placement(
&PlacementBuilder::new(&case_id, &child_id)
.foster_home_id(&home_id)
.placement_type("foster_care")
.build(),
)
.await
.unwrap();
assert_eq!(placement_resp.status, StatusCode::OK);
let placement_body = placement_resp.body.unwrap();
let placement_id = placement_body["id"].as_str().unwrap().to_string();
assert_eq!(placement_body["status"].as_str().unwrap(), "active");
assert_eq!(placement_body["case_id"].as_str().unwrap(), case_id);
assert_eq!(placement_body["child_id"].as_str().unwrap(), child_id);
assert_eq!(
placement_body["foster_home_id"].as_str().unwrap(),
home_id
);
// 6. Verify foster home occupancy incremented
let home_detail = caseworker_placement
.get_foster_home(&home_id)
.await
.unwrap();
assert_eq!(home_detail.status, StatusCode::OK);
let home_data = home_detail.body.unwrap();
assert!(
home_data["current_occupancy"].as_i64().unwrap() >= 1,
"foster home occupancy should increment after active placement"
);
// 7. Create a rate table entry in craig-financial (requires admin)
let rate_resp = admin_financial
.create_rate(&json!({
"jurisdiction": "GA",
"payment_type": "foster_care",
"age_min": 0,
"age_max": 18,
"daily_rate": "25.00",
"effective_date": "2024-01-01"
}))
.await
.unwrap();
assert_eq!(rate_resp.status, StatusCode::OK);
// 8. Calculate payment preview in craig-financial (cross-service reference)
let calc_resp = caseworker_financial
.calculate_payment(&json!({
"case_id": case_id,
"child_id": child_id,
"placement_id": placement_id,
"foster_home_id": home_id,
"payment_type": "foster_care",
"period_start": "2026-03-01",
"period_end": "2026-03-31",
"child_age": 10,
"jurisdiction": "GA"
}))
.await
.unwrap();
assert_eq!(calc_resp.status, StatusCode::OK);
let calc_body = calc_resp.body.unwrap();
// Verify cross-service IDs are correctly echoed
assert_eq!(calc_body["case_id"].as_str().unwrap(), case_id);
assert_eq!(calc_body["child_id"].as_str().unwrap(), child_id);
assert_eq!(calc_body["placement_id"].as_str().unwrap(), placement_id);
assert_eq!(calc_body["foster_home_id"].as_str().unwrap(), home_id);
// Verify payment was calculated (30 days * $25/day = $750)
assert!(
calc_body["day_count"].as_i64().unwrap() == 30,
"period should span 30 days"
);
assert!(
calc_body["gross_amount"].as_str().is_some()
|| calc_body["gross_amount"].as_f64().is_some(),
"gross_amount should be populated"
);
// 9. Verify events (#979: an active placement stages BOTH the lifecycle
// signal and the typed billing trigger; billing rides placement.activated)
let _ = events
.wait_for_event("placement.activated", Duration::from_secs(5))
.await;
events.assert_received("case.created").await;
events.assert_received("placement.created").await;
events.assert_received("placement.activated").await;
events
.assert_payload_contains(
"placement.activated",
"case_id",
&json!(case_id),
)
.await;
}
Step 4: Workflow 3 — Public Report to Referral Conversion
Services touched: craig-intake, craig-cases
Flow: Submit public report → Check status → Caseworker claims report → Convert to referral → Verify referral exists in craig-cases → Verify events
#[tokio::test]
async fn workflow_public_report_to_referral() {
if !devstack_available().await {
return;
}
let harness = TestHarness::new().await.unwrap();
let events = harness
.event_collector(&["intake.#", "case.#"])
.await
.unwrap();
let intake_client = harness.admin_intake_client().await.unwrap();
let caseworker_intake = harness.caseworker_intake_client().await.unwrap();
let caseworker_cases = harness.caseworker_cases_client().await.unwrap();
// 1. Submit a public report (unauthenticated endpoint)
let report_resp = intake_client
.submit_public_report(&json!({
"reporter_type": "mandated",
"reporter_name": "Officer Cross-Service Test",
"reporter_phone": "555-0400",
"reporter_relation": "law enforcement",
"admin_unit": "Fulton",
"concern_type": "physical_abuse",
"concern_description": "Cross-service workflow test: officer observed child with injuries during welfare check at residence.",
"immediate_danger": true,
"children": [{"name": "Workflow Child", "age": "8", "description": "Visible bruising"}],
"alleged_perpetrators": [{"name": "Workflow Adult", "relation": "parent"}]
}))
.await
.unwrap();
assert_eq!(report_resp.status, StatusCode::OK);
let report_body = report_resp.body.unwrap();
let report_id = report_body["id"].as_str().unwrap().to_string();
let confirmation_code = report_body["confirmation_code"]
.as_str()
.unwrap()
.to_string();
// 2. Check public status (unauthenticated) — should be "pending"
let status_resp = intake_client
.check_public_status(&confirmation_code)
.await
.unwrap();
assert_eq!(status_resp.status, StatusCode::OK);
assert_eq!(
status_resp.body.unwrap()["status"].as_str().unwrap(),
"pending"
);
// 3. Caseworker claims the report (pending -> screening)
let claim_resp = caseworker_intake
.claim_report(
&report_id,
&json!({"notes": "Urgent — immediate danger flagged, cross-service test"}),
)
.await
.unwrap();
assert_eq!(claim_resp.status, StatusCode::OK);
assert_eq!(
claim_resp.body.unwrap()["status"].as_str().unwrap(),
"screening"
);
// 4. Convert report to referral (screening -> converted)
// This is the cross-service action: craig-intake calls craig-cases to create a referral.
let convert_resp = caseworker_intake
.convert_report(
&report_id,
&json!({
"priority": "24_hour",
"notes": "Converting due to immediate danger flag — cross-service test"
}),
)
.await
.unwrap();
assert_eq!(convert_resp.status, StatusCode::OK);
let convert_body = convert_resp.body.unwrap();
assert_eq!(convert_body["status"].as_str().unwrap(), "converted");
let referral_id = convert_body["referral_id"]
.as_str()
.expect("referral_id should be populated after conversion")
.to_string();
// 5. Verify public status is now "converted"
let status_resp2 = intake_client
.check_public_status(&confirmation_code)
.await
.unwrap();
assert_eq!(
status_resp2.body.unwrap()["status"].as_str().unwrap(),
"converted"
);
// 6. Cross-service verification: fetch the referral from craig-cases
let referral_resp = caseworker_cases
.get_referral(&referral_id)
.await
.unwrap();
assert_eq!(referral_resp.status, StatusCode::OK);
let referral_body = referral_resp.body.unwrap();
assert_eq!(
referral_body["admin_unit"].as_str().unwrap(),
"Fulton",
"referral admin_unit should match the original report"
);
// 7. Verify events on craig-cases (intake is stateless per ADR-017)
let _ = events
.wait_for_event("case.report_converted", Duration::from_secs(5))
.await;
events.assert_received("case.report_submitted").await;
events.assert_received("case.report_disposition_recorded").await;
events.assert_received("case.report_converted").await;
// The conversion should also trigger a referral creation event in craig-cases
events.assert_received("case.referral_created").await;
}
Step 5: Workflow 4 — Exchange Partner to Transaction
Services touched: craig-exchange
Flow: Create partner → Create agreement → Activate agreement → Send exchange transaction → Verify transaction status → Verify events
#[tokio::test]
async fn workflow_exchange_partner_transaction() {
if !devstack_available().await {
return;
}
let harness = TestHarness::new().await.unwrap();
let events = harness
.event_collector(&["exchange.#"])
.await
.unwrap();
let admin = harness.admin_exchange_client().await.unwrap();
let supervisor = harness.supervisor_exchange_client().await.unwrap();
// 1. Create exchange partner (requires admin)
let partner_resp = admin
.create_partner(
&ExchangePartnerBuilder::new()
.partner_name(harness.rand_name("Workflow Partner"))
.partner_type("state_agency")
.direction("outbound")
.build(),
)
.await
.unwrap();
assert_eq!(partner_resp.status, StatusCode::OK);
let partner_body = partner_resp.body.unwrap();
let partner_id = partner_body["id"].as_str().unwrap().to_string();
assert_eq!(partner_body["active"].as_bool().unwrap(), true);
// 2. Create data sharing agreement (requires admin)
let agreement_resp = admin
.create_agreement(
&AgreementBuilder::new(&partner_id)
.agreement_title(harness.rand_name("Workflow Agreement"))
.build(),
)
.await
.unwrap();
assert_eq!(agreement_resp.status, StatusCode::OK);
let agreement_body = agreement_resp.body.unwrap();
let agreement_id = agreement_body["id"].as_str().unwrap().to_string();
assert_eq!(agreement_body["status"].as_str().unwrap(), "draft");
// 3. Activate the agreement (draft -> active transition)
let activate_resp = admin
.update_agreement(
&agreement_id,
&json!({
"status": "active",
"approved_by": "admin"
}),
)
.await
.unwrap();
assert_eq!(activate_resp.status, StatusCode::OK);
assert_eq!(
activate_resp.body.unwrap()["status"].as_str().unwrap(),
"active"
);
// 4. Link agreement to partner
let link_resp = admin
.update_partner(
&partner_id,
&json!({
"data_sharing_agreement_id": agreement_id
}),
)
.await
.unwrap();
assert_eq!(link_resp.status, StatusCode::OK);
// 5. Send outbound exchange transaction (requires supervisor)
let send_resp = supervisor
.send_exchange(&json!({
"partner_id": partner_id,
"exchange_type": "case_data_export",
"payload": {
"export_type": "monthly_summary",
"period": "2026-03",
"record_count": 42
},
"record_count": 42
}))
.await
.unwrap();
assert_eq!(send_resp.status, StatusCode::OK);
let tx_body = send_resp.body.unwrap();
let tx_id = tx_body["id"].as_str().unwrap().to_string();
// Adapter sends to endpoint_url; with example.com it will fail or succeed
// depending on the adapter implementation. Both outcomes are valid.
let tx_status = tx_body["status"].as_str().unwrap();
assert!(
tx_status == "success" || tx_status == "failed",
"transaction should reach a terminal state, got: {}",
tx_status
);
// 6. Fetch transaction to verify it was persisted
let get_resp = supervisor.get_transaction(&tx_id).await.unwrap();
assert_eq!(get_resp.status, StatusCode::OK);
let get_body = get_resp.body.unwrap();
assert_eq!(get_body["partner_id"].as_str().unwrap(), partner_id);
assert_eq!(get_body["direction"].as_str().unwrap(), "outbound");
assert_eq!(
get_body["exchange_type"].as_str().unwrap(),
"case_data_export"
);
// 7. If transaction failed, test retry mechanism
if tx_status == "failed" {
// Retry requires admin role
let admin_exchange = harness.admin_exchange_client().await.unwrap();
let retry_resp = admin_exchange.retry_transaction(&tx_id).await.unwrap();
assert_eq!(retry_resp.status, StatusCode::OK);
assert_eq!(
retry_resp.body.unwrap()["status"].as_str().unwrap(),
"pending",
"retried transaction should return to pending status"
);
}
// 8. Verify events (at least one of sent/failed should be published)
// Give events time to propagate
tokio::time::sleep(Duration::from_secs(2)).await;
let all_events = events.events().await;
let has_exchange_event = all_events.iter().any(|e| {
let et = e
.get("event_type")
.and_then(|v| v.as_str())
.unwrap_or("");
et == "exchange.sent" || et == "exchange.failed"
});
assert!(
has_exchange_event,
"expected exchange.sent or exchange.failed event, got: {:?}",
all_events
.iter()
.filter_map(|e| e.get("event_type").and_then(|v| v.as_str()).map(String::from))
.collect::<Vec<_>>()
);
}
Files Touched
| File | Change |
|---|---|
|
NEW: 4 cross-service workflow tests |
|
Add |
No changes to crates/craig-test-lib/src/events.rs are needed. The EventCollector is already fully implemented with new(), wait_for_event(), assert_received(), assert_payload_contains(), and events().
|
Verification
-
cargo xtask dev start— full devstack running -
cargo xtask seed— seed data loaded (needed for safety assessment ruleset) -
cargo nextest run --test workflows— all 4 workflow tests pass -
Verify each test cleans up created resources (no test pollution)
-
Run tests 3 times consecutively to verify idempotency
Documentation Updates
-
.claude/docs/testing.md— add section on cross-service workflow tests -
CHANGELOG.adoc— entry under== Unreleased