Skip to main content

haste_testscript_runner/
lib.rs

1use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
2use haste_fhir_client::{
3    FHIRClient,
4    http::{
5        FHIRHttpExchange, HeaderMap, HeaderName, HeaderValue, HttpResponseMetadata, Method,
6        StatusCode, WithRequestHeaders, header,
7    },
8    request::{
9        DeleteRequest, DeleteResponse, FHIRBatchRequest, FHIRConditionalUpdateRequest,
10        FHIRCreateRequest, FHIRDeleteInstanceRequest, FHIRDeleteSystemRequest,
11        FHIRDeleteTypeRequest, FHIRHistoryInstanceRequest, FHIRHistorySystemRequest,
12        FHIRHistoryTypeRequest, FHIRInvokeInstanceRequest, FHIRInvokeSystemRequest,
13        FHIRInvokeTypeRequest, FHIRPatchRequest, FHIRReadRequest, FHIRRequest, FHIRResponse,
14        FHIRSearchTypeRequest, FHIRTransactionRequest, FHIRUpdateInstanceRequest,
15        FHIRVersionReadRequest, HistoryRequest, HistoryResponse, InvocationRequest, InvokeResponse,
16        Operation, SearchRequest, SearchResponse, UpdateRequest,
17    },
18    url::ParsedParameters,
19};
20use haste_fhir_model::r4::generated::{
21    resources::{
22        Resource, ResourceType, TestReport, TestReportSetup, TestReportSetupAction,
23        TestReportSetupActionAssert, TestReportSetupActionOperation, TestReportTeardown,
24        TestReportTeardownAction, TestReportTest, TestReportTestAction, TestScript,
25        TestScriptFixture, TestScriptSetup, TestScriptSetupAction, TestScriptSetupActionAssert,
26        TestScriptSetupActionOperation, TestScriptTeardown, TestScriptTeardownAction,
27        TestScriptTest, TestScriptTestAction, TestScriptVariable,
28    },
29    terminology::{
30        AssertDirectionCodes, AssertOperatorCodes, BoundCode, BundleType, IssueType,
31        ReportActionResultCodes, ReportResultCodes, ReportStatusCodes, TestscriptOperationCodes,
32    },
33    types::{ExtensionValueTypeChoice, FHIRId, FHIRMarkdown, FHIRString, Reference},
34};
35use haste_fhir_operation_error::OperationOutcomeError;
36use haste_pointer::{Key, TypedPointer};
37use haste_reflect::MetaValue;
38use regex::Regex;
39use std::{
40    any::Any,
41    collections::HashMap,
42    sync::{Arc, LazyLock},
43    time::Duration,
44};
45use tokio::sync::Mutex;
46
47use crate::conversion::ConvertedValue;
48
49mod conversion;
50
51#[derive(Debug)]
52pub enum TestScriptError {
53    ExecutionError(String),
54    ValidationError(String),
55    FixtureNotFound,
56    InvalidFixture,
57    OperationError(OperationOutcomeError),
58}
59
60#[derive(Debug, Clone)]
61enum Response {
62    Interaction(Box<FHIRResponse>),
63    OperationError(Arc<OperationOutcomeError>),
64    /// The response to a request that is not a FHIR interaction (an operation
65    /// with a `url` and no `type`): its body, when that is a FHIR resource.
66    Http(Option<Box<Resource>>),
67}
68
69/// A response as fixtures, variables and assertions see it: the FHIR
70/// response, plus the HTTP status and headers it came with (`None` when the
71/// request failed before a response arrived).
72#[derive(Debug, Clone)]
73struct RecordedResponse {
74    response: Response,
75    http: Option<HttpResponseMetadata>,
76}
77
78#[derive(Debug)]
79enum Fixtures {
80    Resource(Resource),
81    Request(FHIRRequest),
82    Response(RecordedResponse),
83}
84
85// Internal structure to hold current test result and testing fixtures.
86struct TestState {
87    fp_engine: haste_fhirpath::FPEngine,
88    fixtures: HashMap<String, Fixtures>,
89    latest_request: Option<FHIRRequest>,
90    latest_response: Option<RecordedResponse>,
91    result: BoundCode<ReportResultCodes>,
92    /// Writes not yet confirmed visible to search, the latest per resource.
93    /// All are waited for, since a search (a chained one especially) may
94    /// filter on any of them.
95    unindexed_writes: Vec<IndexedWrite>,
96}
97
98/// A write that searches must see before they run. Search indexing is
99/// asynchronous, so a search sent straight after a write can miss it. The
100/// worker indexes a tenant's writes in order, so once the latest write is
101/// visible every earlier one is too.
102#[derive(Debug, Clone)]
103struct IndexedWrite {
104    resource_type: ResourceType,
105    id: String,
106    /// The version search should return; `None` for a delete, which search
107    /// should no longer return at all.
108    version_id: Option<String>,
109}
110
111impl TestState {
112    fn new() -> Self {
113        TestState {
114            fp_engine: haste_fhirpath::FPEngine::new(),
115            fixtures: HashMap::new(),
116            latest_request: None,
117            latest_response: None,
118            result: ReportResultCodes::pending(),
119            unindexed_writes: Vec::new(),
120        }
121    }
122    fn resolve_fixture<'a>(
123        &'a self,
124        fixture_id: &str,
125    ) -> Result<&'a dyn MetaValue, TestScriptError> {
126        let fixture = self
127            .fixtures
128            .get(fixture_id)
129            .ok_or(TestScriptError::FixtureNotFound)?;
130
131        match fixture {
132            Fixtures::Resource(res) => Ok(res),
133            Fixtures::Request(req) => {
134                request_to_meta_value(req).ok_or_else(|| TestScriptError::InvalidFixture)
135            }
136            Fixtures::Response(recorded) => response_to_meta_value(&recorded.response)
137                .ok_or_else(|| TestScriptError::InvalidFixture),
138        }
139    }
140
141    /// The response fixture `fixture_id` names, or the latest response.
142    fn resolve_response(
143        &self,
144        fixture_id: Option<&str>,
145    ) -> Result<&RecordedResponse, TestScriptError> {
146        match fixture_id {
147            Some(fixture_id) => match self.fixtures.get(fixture_id) {
148                Some(Fixtures::Response(recorded)) => Ok(recorded),
149                Some(_) => Err(TestScriptError::ExecutionError(format!(
150                    "Fixture '{fixture_id}' is not a response."
151                ))),
152                None => Err(TestScriptError::FixtureNotFound),
153            },
154            None => self.latest_response.as_ref().ok_or_else(|| {
155                TestScriptError::ExecutionError("No response to evaluate yet.".to_string())
156            }),
157        }
158    }
159}
160
161struct TestResult<T> {
162    pub state: Arc<Mutex<TestState>>,
163    pub value: T,
164}
165
166fn response_to_meta_value(response: &Response) -> Option<&dyn MetaValue> {
167    match response {
168        Response::Interaction(fhir_response) => match &**fhir_response {
169            FHIRResponse::Create(res) => Some(&res.resource),
170            FHIRResponse::Read(res) => Some(&res.resource),
171            FHIRResponse::VersionRead(res) => Some(&res.resource),
172            FHIRResponse::Update(res) => Some(&res.resource),
173            FHIRResponse::Patch(res) => Some(&res.resource),
174            FHIRResponse::Batch(res) => Some(&res.resource),
175            FHIRResponse::Transaction(res) => Some(&res.resource),
176
177            FHIRResponse::Capabilities(res) => Some(&res.capabilities),
178            FHIRResponse::Search(res) => match res {
179                SearchResponse::Type(res) => Some(&res.bundle),
180                SearchResponse::System(res) => Some(&res.bundle),
181            },
182            FHIRResponse::History(res) => match res {
183                HistoryResponse::Instance(res) => Some(&res.bundle),
184                HistoryResponse::Type(res) => Some(&res.bundle),
185                HistoryResponse::System(res) => Some(&res.bundle),
186            },
187            FHIRResponse::Invoke(res) => match res {
188                InvokeResponse::Instance(res) => Some(&res.resource),
189                InvokeResponse::Type(res) => Some(&res.resource),
190                InvokeResponse::System(res) => Some(&res.resource),
191            },
192
193            FHIRResponse::Delete(_) | FHIRResponse::Accepted(_) => None,
194        },
195        Response::OperationError(op_error) => {
196            let outcome = op_error.outcome();
197            Some(outcome)
198        }
199        Response::Http(resource) => resource
200            .as_deref()
201            .map(|resource| resource as &dyn MetaValue),
202    }
203}
204
205fn request_to_meta_value(request: &FHIRRequest) -> Option<&dyn MetaValue> {
206    match request {
207        FHIRRequest::Create(req) => Some(&req.resource),
208
209        FHIRRequest::Update(update_request) => match update_request {
210            UpdateRequest::Conditional(req) => Some(&req.resource),
211            UpdateRequest::Instance(req) => Some(&req.resource),
212        },
213
214        FHIRRequest::Batch(req) => Some(&req.resource),
215        FHIRRequest::Transaction(req) => Some(&req.resource),
216        FHIRRequest::Invocation(req) => match req {
217            haste_fhir_client::request::InvocationRequest::Instance(req) => Some(&req.parameters),
218            haste_fhir_client::request::InvocationRequest::Type(req) => Some(&req.parameters),
219            haste_fhir_client::request::InvocationRequest::System(req) => Some(&req.parameters),
220        },
221        FHIRRequest::Read(_)
222        | FHIRRequest::VersionRead(_)
223        | FHIRRequest::Compartment(_)
224        | FHIRRequest::Patch(_)
225        | FHIRRequest::Delete(_)
226        | FHIRRequest::Capabilities
227        | FHIRRequest::Search(_)
228        | FHIRRequest::History(_) => None,
229    }
230}
231
232fn associate_request_response_variables(
233    state: &mut TestState,
234    operation: &TestScriptSetupActionOperation,
235    request: Option<FHIRRequest>,
236    response: RecordedResponse,
237) {
238    if let Some(request) = request.as_ref()
239        && let Some(request_var) = operation
240            .requestId
241            .as_ref()
242            .and_then(|id| id.value.as_ref())
243    {
244        // Associate request variable in state
245        state
246            .fixtures
247            .insert(request_var.clone(), Fixtures::Request(request.clone()));
248    }
249
250    if let Some(response_var) = operation
251        .responseId
252        .as_ref()
253        .and_then(|id| id.value.as_ref())
254    {
255        // Associate response variable in state
256        state
257            .fixtures
258            .insert(response_var.clone(), Fixtures::Response(response.clone()));
259    }
260
261    state.latest_request = request;
262    state.latest_response = Some(response);
263}
264
265/// Derive the resource type from operation or from the metavalue if not present on operation.
266fn derive_resource_type(
267    operation: &TestScriptSetupActionOperation,
268    target: Option<&dyn MetaValue>,
269    path: &str,
270) -> Result<ResourceType, TestScriptError> {
271    if let Some(operation_resource_type) = operation.resource.as_ref() {
272        let string_type = operation_resource_type.as_str();
273        ResourceType::try_from(string_type.unwrap_or_default()).map_err(|_| {
274            TestScriptError::ExecutionError(format!(
275                "Unsupported resource type '{operation_resource_type:?}' for operation at '{path}'."
276            ))
277        })
278    } else if let Some(target) = target {
279        ResourceType::try_from(target.fhir_type()).map_err(|_| {
280            TestScriptError::ExecutionError(format!(
281                "Unsupported resource type '{}' for operation at '{path}'.",
282                target.fhir_type()
283            ))
284        })
285    } else {
286        Err(TestScriptError::ExecutionError(format!(
287            "Failed to derive resource type for operation at '{path}'.",
288        )))
289    }
290}
291
292static EXPRESSION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\{([^}]*)\}").unwrap());
293
294async fn get_variable(
295    state: &TestState,
296    variables: &[TestScriptVariable],
297    variable_id: &str,
298) -> Result<ConvertedValue, TestScriptError> {
299    let Some(variable) = variables
300        .iter()
301        .find(|v| v.name.value.as_deref() == Some(variable_id))
302    else {
303        return Err(TestScriptError::ExecutionError(format!(
304            "Variable with id '{variable_id}' not found."
305        )));
306    };
307
308    if let Some(field) = variable
309        .headerField
310        .as_ref()
311        .and_then(|field| field.value.as_deref())
312    {
313        let source_id = variable
314            .sourceId
315            .as_ref()
316            .and_then(|id| id.value.as_deref());
317        let recorded = state.resolve_response(source_id)?;
318
319        return header_value(recorded, field)
320            .map(ConvertedValue::String)
321            .ok_or_else(|| {
322                TestScriptError::ExecutionError(format!(
323                    "Variable '{variable_id}': the response has no '{field}' header."
324                ))
325            });
326    }
327
328    if let Some(expression) = variable
329        .expression
330        .as_ref()
331        .and_then(|exp| exp.value.as_ref())
332    {
333        let values =
334            if let Some(source_id) = variable.sourceId.as_ref().and_then(|id| id.value.as_ref()) {
335                let source = state.resolve_fixture(source_id)?;
336                vec![source]
337            } else {
338                vec![]
339            };
340
341        let eval_result = state
342            .fp_engine
343            .evaluate(expression, values)
344            .await
345            .map_err(|e| {
346                TestScriptError::ExecutionError(format!(
347                    "Failed to evaluate FHIRPath expression for variable '{variable_id}': {e}"
348                ))
349            })?;
350
351        let converted_values = eval_result
352            .iter()
353            .map(conversion::convert_meta_value)
354            .collect::<Vec<_>>();
355
356        if converted_values.len() == 1 {
357            Ok(converted_values.into_iter().next().unwrap())
358        } else {
359            Err(TestScriptError::ExecutionError(format!(
360                "Variable '{variable_id}' evaluation returned multiple values; only single value supported.",
361            )))
362        }
363    } else {
364        Err(TestScriptError::ExecutionError(format!(
365            "Only support variable with expression or headerField for variable id '{variable_id}'.",
366        )))
367    }
368}
369
370async fn evaluate_variable(
371    state: &TestState,
372    pointer: TypedPointer<TestScript, TestScript>,
373    value: &str,
374) -> Result<String, TestScriptError> {
375    let mut result = value.to_string();
376    let variable_pointer =
377        pointer.descend::<Vec<TestScriptVariable>>(&Key::Field("variable".to_string()));
378    let default_variables = vec![];
379
380    let variables = if let Some(pointer) = variable_pointer.as_ref() {
381        pointer.value().unwrap_or(&default_variables)
382    } else {
383        &default_variables
384    };
385
386    for reg_match in EXPRESSION_REGEX.captures_iter(value) {
387        let full_match = reg_match.get(0).map_or("", |m| m.as_str());
388        let Some(variable_id) = reg_match.get(1).map(|m| m.as_str()) else {
389            return Err(TestScriptError::ExecutionError(format!(
390                "Invalid variable expression in '{value}'."
391            )));
392        };
393
394        let variable = get_variable(state, variables, variable_id).await?;
395        result = result.replace(full_match, variable.to_string().as_str());
396    }
397
398    Ok(result)
399}
400
401/// What an operation sends: a FHIR interaction, or a plain HTTP request.
402enum OperationRequest {
403    Interaction(Box<FHIRRequest>),
404    Http { method: Method, url: String },
405}
406
407async fn testscript_operation_to_request(
408    state: &TestState,
409    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
410) -> Result<OperationRequest, TestScriptError> {
411    let operation = get_operation(pointer)?;
412
413    // An operation with a `url` and no `type` is a plain HTTP request to that
414    // (variable-evaluated) URL by its `method`: how a `TestScript` reaches what
415    // is not a FHIR interaction, such as an asynchronous request's status URL.
416    if get_operation_type(operation).is_none()
417        && let Some(url) = operation.url.as_ref().and_then(|url| url.value.as_deref())
418    {
419        // The method codes are the HTTP methods in lower case.
420        let method = operation
421            .method
422            .as_ref()
423            .and_then(BoundCode::as_str)
424            .unwrap_or("get");
425        let method = Method::from_bytes(method.to_ascii_uppercase().as_bytes()).map_err(|_| {
426            TestScriptError::ExecutionError(format!(
427                "Unsupported method '{method}' for operation at '{}'.",
428                pointer.path()
429            ))
430        })?;
431
432        return Ok(OperationRequest::Http {
433            method,
434            url: evaluate_variable(state, pointer.root(), url).await?,
435        });
436    }
437
438    testscript_operation_to_fhir_request(state, pointer)
439        .await
440        .map(|request| OperationRequest::Interaction(Box::new(request)))
441}
442
443async fn testscript_operation_to_fhir_request(
444    state: &TestState,
445    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
446) -> Result<FHIRRequest, TestScriptError> {
447    let operation = get_operation(pointer)?;
448    let op = get_operation_type(operation);
449
450    match op {
451        Some(op) if Some(op) == TestscriptOperationCodes::read().as_str() => {
452            read_request(state, pointer, operation)
453        }
454
455        Some(op) if Some(op) == TestscriptOperationCodes::vread().as_str() => {
456            version_read_request(state, pointer, operation)
457        }
458
459        Some(op) if Some(op) == TestscriptOperationCodes::search().as_str() => {
460            search_request(state, pointer, operation).await
461        }
462
463        Some(op) if Some(op) == TestscriptOperationCodes::history().as_str() => {
464            history_request(state, pointer, operation).await
465        }
466
467        Some(op) if Some(op) == TestscriptOperationCodes::transaction().as_str() => {
468            transaction_request(state, pointer, operation)
469        }
470
471        Some(op) if Some(op) == TestscriptOperationCodes::batch().as_str() => {
472            batch_request(state, pointer, operation)
473        }
474
475        Some(op) if Some(op) == TestscriptOperationCodes::create().as_str() => {
476            create_request(state, pointer, operation)
477        }
478
479        Some(op) if Some(op) == TestscriptOperationCodes::update().as_str() => {
480            update_request(state, pointer, operation)
481        }
482
483        Some(op) if Some(op) == TestscriptOperationCodes::update_create().as_str() => {
484            conditional_update_request(state, pointer, operation).await
485        }
486
487        Some(op) if Some(op) == TestscriptOperationCodes::patch().as_str() => {
488            patch_request(state, pointer, operation)
489        }
490
491        Some(op) if Some(op) == TestscriptOperationCodes::delete().as_str() => {
492            delete_request(state, pointer, operation)
493        }
494
495        Some(op)
496            if Some(op) == TestscriptOperationCodes::delete_cond_multiple().as_str()
497                || Some(op) == TestscriptOperationCodes::delete_cond_single().as_str() =>
498        {
499            conditional_delete_request(state, pointer, operation)
500        }
501
502        Some(op)
503            if Some(op) == TestscriptOperationCodes::capabilities().as_str()
504                || Some(op) == TestscriptOperationCodes::conforms().as_str() =>
505        {
506            Ok(FHIRRequest::Capabilities)
507        }
508
509        Some("invoke") => invoke_request(state, pointer, operation),
510
511        _ => Err(TestScriptError::ExecutionError(format!(
512            "Unsupported TestScript operation type: {op:?} at '{}'.",
513            pointer.path(),
514        ))),
515    }
516}
517
518fn read_request(
519    state: &TestState,
520    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
521    operation: &TestScriptSetupActionOperation,
522) -> Result<FHIRRequest, TestScriptError> {
523    let target_id = require_target_id(operation, pointer.path(), "Read")?;
524    let target = state.resolve_fixture(target_id)?;
525
526    Ok(FHIRRequest::Read(FHIRReadRequest {
527        resource_type: derive_resource_type(operation, Some(target), pointer.path())?,
528        id: fixture_string_field(target, target_id, "id")?,
529    }))
530}
531
532fn version_read_request(
533    state: &TestState,
534    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
535    operation: &TestScriptSetupActionOperation,
536) -> Result<FHIRRequest, TestScriptError> {
537    let target_id = require_target_id(operation, pointer.path(), "Version Read")?;
538    let target = state.resolve_fixture(target_id)?;
539
540    Ok(FHIRRequest::VersionRead(FHIRVersionReadRequest {
541        resource_type: derive_resource_type(operation, Some(target), pointer.path())?,
542        id: fixture_string_field(target, target_id, "id")?,
543        version_id: fixture_version_id(target, target_id)?.into(),
544    }))
545}
546
547async fn search_request(
548    state: &TestState,
549    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
550    operation: &TestScriptSetupActionOperation,
551) -> Result<FHIRRequest, TestScriptError> {
552    let query = operation
553        .params
554        .as_ref()
555        .and_then(|p| p.value.as_deref())
556        .unwrap_or_default();
557
558    let parameters =
559        parsed_parameters(state, pointer.root(), query, pointer.path(), "Search").await?;
560
561    if let Ok(resource_type) = derive_resource_type(operation, None, pointer.path()) {
562        Ok(FHIRRequest::Search(
563            haste_fhir_client::request::SearchRequest::Type(
564                haste_fhir_client::request::FHIRSearchTypeRequest {
565                    resource_type,
566                    parameters,
567                },
568            ),
569        ))
570    } else {
571        Ok(FHIRRequest::Search(
572            haste_fhir_client::request::SearchRequest::System(
573                haste_fhir_client::request::FHIRSearchSystemRequest { parameters },
574            ),
575        ))
576    }
577}
578
579async fn history_request(
580    state: &TestState,
581    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
582    operation: &TestScriptSetupActionOperation,
583) -> Result<FHIRRequest, TestScriptError> {
584    let parameters = parsed_parameters(
585        state,
586        pointer.root(),
587        operation
588            .params
589            .as_ref()
590            .and_then(|p| p.value.as_deref())
591            .unwrap_or_default(),
592        pointer.path(),
593        "History",
594    )
595    .await?;
596
597    if let Some(target_id) = operation
598        .targetId
599        .as_ref()
600        .and_then(|id| id.value.as_deref())
601    {
602        let target = state.resolve_fixture(target_id)?;
603
604        Ok(FHIRRequest::History(HistoryRequest::Instance(
605            FHIRHistoryInstanceRequest {
606                resource_type: derive_resource_type(operation, Some(target), pointer.path())?,
607                id: fixture_string_field(target, target_id, "id")?,
608                parameters,
609            },
610        )))
611    } else if operation.resource.is_some() {
612        Ok(FHIRRequest::History(HistoryRequest::Type(
613            FHIRHistoryTypeRequest {
614                resource_type: derive_resource_type(operation, None, pointer.path())?,
615                parameters,
616            },
617        )))
618    } else {
619        Ok(FHIRRequest::History(HistoryRequest::System(
620            FHIRHistorySystemRequest { parameters },
621        )))
622    }
623}
624
625fn transaction_request(
626    state: &TestState,
627    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
628    operation: &TestScriptSetupActionOperation,
629) -> Result<FHIRRequest, TestScriptError> {
630    let source_id = require_source_id(operation, pointer.path(), "Transaction")?;
631    let source = state.resolve_fixture(source_id)?;
632    let resource = fixture_resource(source, source_id)?;
633
634    match resource {
635        Resource::Bundle(bundle) => {
636            if bundle.type_ != BundleType::transaction() {
637                return Err(TestScriptError::ExecutionError(format!(
638                    "Fixture must be a transaction bundle for transaction operations for sourceId '{source_id}'."
639                )));
640            }
641
642            Ok(FHIRRequest::Transaction(FHIRTransactionRequest {
643                resource: bundle,
644            }))
645        }
646        _ => Err(TestScriptError::ExecutionError(format!(
647            "Fixture '{source_id}' is not a transaction Bundle resource."
648        ))),
649    }
650}
651
652fn batch_request(
653    state: &TestState,
654    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
655    operation: &TestScriptSetupActionOperation,
656) -> Result<FHIRRequest, TestScriptError> {
657    let source_id = require_source_id(operation, pointer.path(), "Batch")?;
658    let source = state.resolve_fixture(source_id)?;
659    let resource = fixture_resource(source, source_id)?;
660
661    match resource {
662        Resource::Bundle(bundle) => {
663            if bundle.type_ != BundleType::batch() {
664                return Err(TestScriptError::ExecutionError(format!(
665                    "Fixture must be a batch bundle for batch operations for sourceId '{source_id}'."
666                )));
667            }
668
669            Ok(FHIRRequest::Batch(FHIRBatchRequest { resource: bundle }))
670        }
671        _ => Err(TestScriptError::ExecutionError(format!(
672            "Fixture '{source_id}' is not a batch Bundle resource."
673        ))),
674    }
675}
676
677fn create_request(
678    state: &TestState,
679    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
680    operation: &TestScriptSetupActionOperation,
681) -> Result<FHIRRequest, TestScriptError> {
682    let source_id = require_source_id(operation, pointer.path(), "Create")?;
683    let source = state.resolve_fixture(source_id)?;
684    let resource = fixture_resource(source, source_id)?;
685
686    Ok(FHIRRequest::Create(FHIRCreateRequest {
687        resource_type: derive_resource_type(operation, Some(source), pointer.path())?,
688        resource,
689    }))
690}
691
692fn update_request(
693    state: &TestState,
694    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
695    operation: &TestScriptSetupActionOperation,
696) -> Result<FHIRRequest, TestScriptError> {
697    let source_id = require_source_id(operation, pointer.path(), "Update")?;
698    let source = state.resolve_fixture(source_id)?;
699    let mut resource = fixture_resource(source, source_id)?;
700
701    let target_id = require_target_id(operation, pointer.path(), "Update")?;
702    let target = state.resolve_fixture(target_id)?;
703    let target_resource = fixture_resource(target, target_id)?;
704    let id = fixture_string_field(&target_resource, target_id, "id")?;
705
706    // A contained fixture's id is local to the TestScript. The update
707    // addresses the target, and the server refuses a body naming another id.
708    if let Some(body_id) = resource
709        .get_field_mut("id")
710        .and_then(|body_id| (body_id as &mut dyn Any).downcast_mut::<Option<String>>())
711    {
712        *body_id = Some(id.clone());
713    }
714
715    Ok(FHIRRequest::Update(UpdateRequest::Instance(
716        FHIRUpdateInstanceRequest {
717            resource_type: derive_resource_type(operation, Some(target), pointer.path())?,
718            id,
719            resource,
720        },
721    )))
722}
723
724async fn conditional_update_request(
725    state: &TestState,
726    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
727    operation: &TestScriptSetupActionOperation,
728) -> Result<FHIRRequest, TestScriptError> {
729    let source_id = require_source_id(operation, pointer.path(), "UpdateCreate")?;
730    let source = state.resolve_fixture(source_id)?;
731    let resource = fixture_resource(source, source_id)?;
732
733    let parameters = parsed_parameters(
734        state,
735        pointer.root(),
736        operation
737            .params
738            .as_ref()
739            .and_then(|p| p.value.as_deref())
740            .unwrap_or_default(),
741        pointer.path(),
742        "UpdateCreate",
743    )
744    .await?;
745
746    Ok(FHIRRequest::Update(UpdateRequest::Conditional(
747        FHIRConditionalUpdateRequest {
748            resource_type: derive_resource_type(operation, Some(source), pointer.path())?,
749            parameters,
750            resource,
751        },
752    )))
753}
754
755fn delete_request(
756    state: &TestState,
757    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
758    operation: &TestScriptSetupActionOperation,
759) -> Result<FHIRRequest, TestScriptError> {
760    let target_id = require_target_id(operation, pointer.path(), "Delete")?;
761    let target = state.resolve_fixture(target_id)?;
762
763    Ok(FHIRRequest::Delete(DeleteRequest::Instance(
764        FHIRDeleteInstanceRequest {
765            resource_type: derive_resource_type(operation, Some(target), pointer.path())?,
766            id: fixture_string_field(target, target_id, "id")?,
767        },
768    )))
769}
770
771fn conditional_delete_request(
772    _state: &TestState,
773    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
774    operation: &TestScriptSetupActionOperation,
775) -> Result<FHIRRequest, TestScriptError> {
776    let parameters = ParsedParameters::try_from(
777        operation
778            .params
779            .as_ref()
780            .and_then(|p| p.value.as_deref())
781            .unwrap_or_default(),
782    )
783    .map_err(|e| {
784        TestScriptError::ExecutionError(format!(
785            "Failed to parse parameters for conditional delete operation at '{}': {}",
786            pointer.path(),
787            e
788        ))
789    })?;
790
791    if operation.resource.is_some() {
792        Ok(FHIRRequest::Delete(DeleteRequest::Type(
793            FHIRDeleteTypeRequest {
794                resource_type: derive_resource_type(operation, None, pointer.path())?,
795                parameters,
796            },
797        )))
798    } else {
799        Ok(FHIRRequest::Delete(DeleteRequest::System(
800            FHIRDeleteSystemRequest { parameters },
801        )))
802    }
803}
804
805/// The patch source fixture must be a `Binary` resource carrying the JSON Patch
806/// document, base64-encoded in `data` with `contentType`
807/// `application/json-patch+json` — the standard way `TestScript` represents patch
808/// bodies, since there's no dedicated FHIR resource type for a JSON Patch document.
809fn patch_request(
810    state: &TestState,
811    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
812    operation: &TestScriptSetupActionOperation,
813) -> Result<FHIRRequest, TestScriptError> {
814    let target_id = require_target_id(operation, pointer.path(), "Patch")?;
815    let target = state.resolve_fixture(target_id)?;
816
817    let source_id = require_source_id(operation, pointer.path(), "Patch")?;
818    let source = state.resolve_fixture(source_id)?;
819    let Resource::Binary(binary) = fixture_resource(source, source_id)? else {
820        return Err(TestScriptError::ExecutionError(format!(
821            "Patch source fixture '{source_id}' must be a Binary resource containing the JSON Patch document."
822        )));
823    };
824
825    let content_type = binary.contentType.value.as_deref().unwrap_or_default();
826    if content_type != "application/json-patch+json" {
827        return Err(TestScriptError::ExecutionError(format!(
828            "Unsupported patch content type '{content_type}' for fixture '{source_id}'; only 'application/json-patch+json' is supported."
829        )));
830    }
831
832    let encoded = binary
833        .data
834        .as_ref()
835        .and_then(|d| d.value.as_deref())
836        .ok_or_else(|| {
837            TestScriptError::ExecutionError(format!(
838                "Patch source fixture '{source_id}' Binary resource has no 'data'."
839            ))
840        })?;
841
842    let decoded = BASE64.decode(encoded).map_err(|e| {
843        TestScriptError::ExecutionError(format!(
844            "Failed to base64-decode patch content for fixture '{source_id}': {e}"
845        ))
846    })?;
847
848    let patch: json_patch::Patch = serde_json::from_slice(&decoded).map_err(|e| {
849        TestScriptError::ExecutionError(format!(
850            "Failed to parse JSON Patch document for fixture '{source_id}': {e}"
851        ))
852    })?;
853
854    Ok(FHIRRequest::Patch(FHIRPatchRequest {
855        resource_type: derive_resource_type(operation, Some(target), pointer.path())?,
856        id: fixture_string_field(target, target_id, "id")?,
857        patch,
858    }))
859}
860
861fn invoke_request(
862    state: &TestState,
863    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
864    operation: &TestScriptSetupActionOperation,
865) -> Result<FHIRRequest, TestScriptError> {
866    let op_code = operation
867        .url
868        .as_ref()
869        .and_then(|u| u.value.as_deref())
870        .ok_or_else(|| {
871            TestScriptError::ExecutionError(format!(
872                "Invoke operation requires url at '{}' which is used for the operation code.",
873                pointer.path()
874            ))
875        })?;
876
877    let fhir_operation = Operation::new(op_code);
878
879    let source_id = require_source_id(operation, pointer.path(), "Invoke")?;
880    let source = state.resolve_fixture(source_id)?;
881
882    let Resource::Parameters(parameters) = fixture_resource(source, source_id)? else {
883        return Err(TestScriptError::ExecutionError(format!(
884            "Source fixture '{source_id}' is not a Parameters resource."
885        )));
886    };
887
888    if let Some(target_id) = operation
889        .targetId
890        .as_ref()
891        .and_then(|id| id.value.as_deref())
892    {
893        let target = state.resolve_fixture(target_id)?;
894
895        Ok(FHIRRequest::Invocation(InvocationRequest::Instance(
896            FHIRInvokeInstanceRequest {
897                operation: fhir_operation,
898                resource_type: derive_resource_type(operation, Some(target), pointer.path())?,
899                id: fixture_string_field(target, target_id, "id")?,
900                parameters,
901            },
902        )))
903    } else if let Ok(resource_type) = derive_resource_type(operation, None, pointer.path()) {
904        Ok(FHIRRequest::Invocation(InvocationRequest::Type(
905            FHIRInvokeTypeRequest {
906                operation: fhir_operation,
907                resource_type,
908                parameters,
909            },
910        )))
911    } else {
912        Ok(FHIRRequest::Invocation(InvocationRequest::System(
913            FHIRInvokeSystemRequest {
914                operation: fhir_operation,
915                parameters,
916            },
917        )))
918    }
919}
920
921fn get_operation(
922    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
923) -> Result<&TestScriptSetupActionOperation, TestScriptError> {
924    pointer.value().ok_or_else(|| {
925        TestScriptError::ExecutionError(format!(
926            "Failed to retrieve TestScript operation at '{}'.",
927            pointer.path()
928        ))
929    })
930}
931
932fn get_operation_type(operation: &TestScriptSetupActionOperation) -> Option<&str> {
933    operation
934        .type_
935        .as_ref()
936        .and_then(|t| t.code.as_ref())
937        .and_then(|c| c.value.as_deref())
938}
939
940fn require_target_id<'a>(
941    operation: &'a TestScriptSetupActionOperation,
942    path: &str,
943    operation_name: &str,
944) -> Result<&'a str, TestScriptError> {
945    operation
946        .targetId
947        .as_ref()
948        .and_then(|id| id.value.as_deref())
949        .ok_or_else(|| {
950            TestScriptError::ExecutionError(format!(
951                "{operation_name} operation requires targetId at '{path}'."
952            ))
953        })
954}
955
956fn require_source_id<'a>(
957    operation: &'a TestScriptSetupActionOperation,
958    path: &str,
959    operation_name: &str,
960) -> Result<&'a str, TestScriptError> {
961    operation
962        .sourceId
963        .as_ref()
964        .and_then(|id| id.value.as_deref())
965        .ok_or_else(|| {
966            TestScriptError::ExecutionError(format!(
967                "{operation_name} operation requires sourceId at '{path}'."
968            ))
969        })
970}
971
972fn fixture_string_field(
973    fixture: &dyn MetaValue,
974    fixture_name: &str,
975    field: &str,
976) -> Result<String, TestScriptError> {
977    fixture
978        .get_field(field)
979        .ok_or_else(|| {
980            TestScriptError::ExecutionError(format!(
981                "Fixture '{fixture_name}' does not have '{field}' field."
982            ))
983        })?
984        .as_any()
985        .downcast_ref::<String>()
986        .cloned()
987        .ok_or_else(|| {
988            TestScriptError::ExecutionError(format!(
989                "Field '{field}' on fixture '{fixture_name}' is not a String."
990            ))
991        })
992}
993
994fn fixture_version_id(
995    fixture: &dyn MetaValue,
996    fixture_name: &str,
997) -> Result<String, TestScriptError> {
998    fixture
999        .get_field("meta")
1000        .and_then(|meta| meta.get_field("versionId"))
1001        .ok_or_else(|| {
1002            TestScriptError::ExecutionError(format!(
1003                "Fixture '{fixture_name}' does not have a 'versionId' field."
1004            ))
1005        })?
1006        .as_any()
1007        .downcast_ref::<FHIRId>()
1008        .and_then(|v| v.value.clone())
1009        .ok_or_else(|| {
1010            TestScriptError::ExecutionError(format!(
1011                "Fixture '{fixture_name}' does not have a valid 'versionId' field."
1012            ))
1013        })
1014}
1015
1016fn fixture_resource(
1017    fixture: &dyn MetaValue,
1018    fixture_name: &str,
1019) -> Result<Resource, TestScriptError> {
1020    (fixture as &dyn Any)
1021        .downcast_ref::<Resource>()
1022        .cloned()
1023        .ok_or_else(|| {
1024            TestScriptError::ExecutionError(format!("Fixture '{fixture_name}' is not a Resource."))
1025        })
1026}
1027
1028async fn parsed_parameters(
1029    state: &TestState,
1030    root: TypedPointer<TestScript, TestScript>,
1031    raw: &str,
1032    path: &str,
1033    operation: &str,
1034) -> Result<ParsedParameters, TestScriptError> {
1035    let evaluated = evaluate_variable(state, root, raw).await?;
1036
1037    ParsedParameters::try_from(evaluated.as_str()).map_err(|e| {
1038        TestScriptError::ExecutionError(format!(
1039            "Failed to parse parameters for {operation} operation at '{path}': {e}"
1040        ))
1041    })
1042}
1043
1044/// Builds the headers declared by an operation's `requestHeader` entries.
1045///
1046/// Values are variable-evaluated first, so a `TestScript` may parameterize a header
1047/// the same way it parameterizes a URL or a search query.
1048async fn testscript_request_headers(
1049    state: &TestState,
1050    pointer: &TypedPointer<TestScript, TestScriptSetupActionOperation>,
1051    operation: &TestScriptSetupActionOperation,
1052) -> Result<Option<HeaderMap>, TestScriptError> {
1053    let Some(request_headers) = operation.requestHeader.as_ref() else {
1054        return Ok(None);
1055    };
1056
1057    let mut headers = HeaderMap::with_capacity(request_headers.len());
1058
1059    for request_header in request_headers {
1060        let field = request_header.field.value.as_deref().ok_or_else(|| {
1061            TestScriptError::ExecutionError(format!(
1062                "Missing requestHeader field for operation at '{}'.",
1063                pointer.path()
1064            ))
1065        })?;
1066
1067        let raw_value = request_header.value.value.as_deref().unwrap_or_default();
1068        let value = evaluate_variable(state, pointer.root(), raw_value).await?;
1069
1070        let name = HeaderName::try_from(field).map_err(|e| {
1071            TestScriptError::ExecutionError(format!(
1072                "Invalid requestHeader field '{field}' for operation at '{}': {e}",
1073                pointer.path()
1074            ))
1075        })?;
1076
1077        let value = HeaderValue::from_str(&value).map_err(|e| {
1078            TestScriptError::ExecutionError(format!(
1079                "Invalid requestHeader value for '{field}' for operation at '{}': {e}",
1080                pointer.path()
1081            ))
1082        })?;
1083
1084        headers.insert(name, value);
1085    }
1086
1087    Ok((!headers.is_empty()).then_some(headers))
1088}
1089
1090/// How often search is polled while waiting for a write to be indexed.
1091const INDEX_POLL_INTERVAL: Duration = Duration::from_millis(100);
1092
1093/// The write a response records, for requests that change a resource.
1094fn indexed_write(response: &FHIRResponse) -> Option<IndexedWrite> {
1095    let (resource, deleted) = match response {
1096        FHIRResponse::Create(created) => (&created.resource, false),
1097        FHIRResponse::Update(updated) => (&updated.resource, false),
1098        FHIRResponse::Patch(patched) => (&patched.resource, false),
1099        FHIRResponse::Delete(DeleteResponse::Instance(deleted)) => (&deleted.resource, true),
1100        _ => return None,
1101    };
1102    let json = serde_json::to_value(resource).ok()?;
1103    Some(IndexedWrite {
1104        resource_type: ResourceType::try_from(json.get("resourceType")?.as_str()?).ok()?,
1105        id: json.get("id")?.as_str()?.to_string(),
1106        version_id: if deleted {
1107            None
1108        } else {
1109            Some(json.pointer("/meta/versionId")?.as_str()?.to_string())
1110        },
1111    })
1112}
1113
1114/// Whether a request's result depends on the search index. Operations
1115/// count: `$everything`, `$expand` and `$viewdefinition-run` search for what
1116/// they return, and for one that does not the wait is one `_id` search per
1117/// pending write.
1118fn reads_search_index(request: &FHIRRequest) -> bool {
1119    matches!(
1120        request,
1121        FHIRRequest::Search(_)
1122            | FHIRRequest::Delete(DeleteRequest::Type(_) | DeleteRequest::System(_))
1123            | FHIRRequest::Update(UpdateRequest::Conditional(_))
1124            | FHIRRequest::Invocation(_)
1125    )
1126}
1127
1128/// Polls search until `write` is visible (its version returned, or gone after
1129/// a delete) or `timeout` runs out. On timeout the caller carries on, and a
1130/// search that depended on the write fails its assertions as usual.
1131async fn wait_for_index<CTX: Clone, Client: FHIRClient<CTX, OperationOutcomeError>>(
1132    client: &Client,
1133    ctx: &CTX,
1134    write: &IndexedWrite,
1135    timeout: Duration,
1136) {
1137    let Ok(parameters) = ParsedParameters::try_from(format!("_id={}", write.id).as_str()) else {
1138        return;
1139    };
1140    let request = FHIRRequest::Search(SearchRequest::Type(FHIRSearchTypeRequest {
1141        resource_type: write.resource_type.clone(),
1142        parameters,
1143    }));
1144    let deadline = tokio::time::Instant::now() + timeout;
1145
1146    loop {
1147        let versions: Vec<String> = match client.request(ctx.clone(), request.clone()).await {
1148            Ok(FHIRResponse::Search(SearchResponse::Type(response))) => response
1149                .bundle
1150                .entry
1151                .unwrap_or_default()
1152                .into_iter()
1153                .filter_map(|entry| {
1154                    let json = serde_json::to_value(entry.resource?).ok()?;
1155                    Some(json.pointer("/meta/versionId")?.as_str()?.to_string())
1156                })
1157                .collect(),
1158            _ => vec![],
1159        };
1160        let visible = match &write.version_id {
1161            Some(version_id) => versions.contains(version_id),
1162            None => versions.is_empty(),
1163        };
1164        if visible {
1165            return;
1166        }
1167        if tokio::time::Instant::now() >= deadline {
1168            tracing::warn!(
1169                resource_type = write.resource_type.as_ref(),
1170                id = write.id,
1171                "Timed out waiting for a write to be indexed"
1172            );
1173            return;
1174        }
1175        tokio::time::sleep(INDEX_POLL_INTERVAL).await;
1176    }
1177}
1178
1179/// The extension that repeats an operation while its response has the status
1180/// in its `valueString` (such as `"202"`), as a client polls an asynchronous
1181/// request's status URL. Only the last response is recorded.
1182const POLL_EXTENSION_URL: &str =
1183    "https://haste.health/fhir/StructureDefinition/testscript-poll-while-response-code";
1184
1185/// How long an operation is polled before the last response is taken as it is.
1186const POLL_TIMEOUT: Duration = Duration::from_secs(60);
1187
1188/// The shortest wait between polls, and the wait without a `Retry-After`.
1189const POLL_INTERVAL: Duration = Duration::from_secs(1);
1190
1191/// The status the operation's poll extension repeats it on, if it has one.
1192fn poll_while(
1193    operation: &TestScriptSetupActionOperation,
1194    path: &str,
1195) -> Result<Option<StatusCode>, TestScriptError> {
1196    let Some(extension) = operation
1197        .extension
1198        .as_ref()
1199        .and_then(|extensions| extensions.iter().find(|e| e.url == POLL_EXTENSION_URL))
1200    else {
1201        return Ok(None);
1202    };
1203
1204    match &extension.value {
1205        Some(ExtensionValueTypeChoice::String(code)) => code
1206            .value
1207            .as_deref()
1208            .and_then(|code| StatusCode::from_bytes(code.trim().as_bytes()).ok()),
1209        _ => None,
1210    }
1211    .map(Some)
1212    .ok_or_else(|| {
1213        TestScriptError::ExecutionError(format!(
1214            "The poll extension on the operation at '{path}' needs a valueString holding an \
1215             HTTP status code."
1216        ))
1217    })
1218}
1219
1220/// A response header's value, with repeats joined as HTTP allows (`, `).
1221fn header_value(recorded: &RecordedResponse, field: &str) -> Option<String> {
1222    let headers = &recorded.http.as_ref()?.headers;
1223    let values: Vec<&str> = headers
1224        .get_all(field)
1225        .iter()
1226        .filter_map(|value| value.to_str().ok())
1227        .collect();
1228
1229    (!values.is_empty()).then(|| values.join(", "))
1230}
1231
1232/// `Retry-After` in its delay-seconds form.
1233fn retry_after(recorded: &RecordedResponse) -> Option<Duration> {
1234    header_value(recorded, header::RETRY_AFTER.as_str())?
1235        .trim()
1236        .parse::<u64>()
1237        .ok()
1238        .map(Duration::from_secs)
1239}
1240
1241async fn send_operation<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
1242    client: &Client,
1243    ctx: CTX,
1244    request: &OperationRequest,
1245) -> RecordedResponse {
1246    match request {
1247        OperationRequest::Interaction(request) => {
1248            let (http, result) = client
1249                .request_with_metadata(ctx, request.as_ref().clone())
1250                .await;
1251            let response = match result {
1252                Ok(response) => Response::Interaction(Box::new(response)),
1253                Err(error) => Response::OperationError(Arc::new(error)),
1254            };
1255
1256            RecordedResponse { response, http }
1257        }
1258        OperationRequest::Http { method, url } => match client.send(ctx, method.clone(), url).await
1259        {
1260            Ok((metadata, body)) => RecordedResponse {
1261                // A body that is not a FHIR resource (a Bulk Data manifest,
1262                // say) is left to the HTTP-level assertions.
1263                response: Response::Http(
1264                    serde_json::from_slice::<Resource>(&body).ok().map(Box::new),
1265                ),
1266                http: Some(metadata),
1267            },
1268            Err(error) => RecordedResponse {
1269                response: Response::OperationError(Arc::new(error)),
1270                http: None,
1271            },
1272        },
1273    }
1274}
1275
1276/// Sends the operation, and while its response has the `poll_while` status
1277/// sends it again, waiting as long as `Retry-After` says, up to
1278/// [`POLL_TIMEOUT`]. Returns the last response.
1279async fn send_polling<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
1280    client: &Client,
1281    ctx: CTX,
1282    request: &OperationRequest,
1283    poll_while: Option<StatusCode>,
1284) -> RecordedResponse {
1285    let Some(poll_while) = poll_while else {
1286        return send_operation(client, ctx, request).await;
1287    };
1288    let deadline = tokio::time::Instant::now() + POLL_TIMEOUT;
1289
1290    loop {
1291        let recorded = send_operation(client, ctx.clone(), request).await;
1292        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1293        if recorded.http.as_ref().map(|http| http.status) != Some(poll_while) || remaining.is_zero()
1294        {
1295            return recorded;
1296        }
1297
1298        let wait = retry_after(&recorded)
1299            .unwrap_or_default()
1300            .max(POLL_INTERVAL)
1301            .min(remaining);
1302        tokio::time::sleep(wait).await;
1303    }
1304}
1305
1306async fn run_operation<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
1307    client: &Client,
1308    ctx: CTX,
1309    state: Arc<Mutex<TestState>>,
1310    pointer: TypedPointer<TestScript, TestScriptSetupActionOperation>,
1311    options: Arc<TestRunnerOptions>,
1312) -> Result<TestResult<TestReportSetupActionOperation>, TestScriptError> {
1313    let operation = pointer.value().ok_or_else(|| {
1314        TestScriptError::ExecutionError(format!(
1315            "Failed to retrieve TestScript operation at '{}'.",
1316            pointer.path()
1317        ))
1318    })?;
1319
1320    let mut state_guard = state.lock().await;
1321    let request = testscript_operation_to_request(&state_guard, &pointer).await?;
1322
1323    if let Some(timeout) = options.index_wait_timeout
1324        && let OperationRequest::Interaction(fhir_request) = &request
1325        && reads_search_index(fhir_request)
1326        && !state_guard.unindexed_writes.is_empty()
1327    {
1328        // One deadline for the whole set: the worker indexes them together.
1329        let deadline = tokio::time::Instant::now() + timeout;
1330        for write in std::mem::take(&mut state_guard.unindexed_writes) {
1331            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1332            if remaining.is_zero() {
1333                break;
1334            }
1335            wait_for_index(client, &ctx, &write, remaining).await;
1336        }
1337    }
1338
1339    let ctx = match testscript_request_headers(&state_guard, &pointer, operation).await? {
1340        Some(headers) => ctx.with_request_headers(headers),
1341        None => ctx,
1342    };
1343
1344    let poll_while = poll_while(operation, pointer.path())?;
1345    let recorded = send_polling(client, ctx, &request, poll_while).await;
1346
1347    if let Some(wait_duration) = options.wait_between_operations {
1348        tokio::time::sleep(wait_duration).await;
1349    }
1350
1351    let failure = match &recorded.response {
1352        Response::Interaction(response) => {
1353            if let Some(write) = indexed_write(response) {
1354                // Search returns only a resource's current version, so an
1355                // earlier write of the same resource could never be seen.
1356                state_guard.unindexed_writes.retain(|earlier| {
1357                    (&earlier.resource_type, &earlier.id) != (&write.resource_type, &write.id)
1358                });
1359                state_guard.unindexed_writes.push(write);
1360            }
1361            None
1362        }
1363        Response::OperationError(error) => Some(error.to_string()),
1364        Response::Http(_) => recorded
1365            .http
1366            .as_ref()
1367            .filter(|http| !http.status.is_success())
1368            .map(|http| format!("HTTP {}", http.status)),
1369    };
1370
1371    let fhir_request = match request {
1372        OperationRequest::Interaction(request) => Some(*request),
1373        OperationRequest::Http { .. } => None,
1374    };
1375    associate_request_response_variables(&mut state_guard, operation, fhir_request, recorded);
1376    drop(state_guard);
1377
1378    let value = match failure {
1379        None => TestReportSetupActionOperation {
1380            result: ReportActionResultCodes::pass(),
1381            ..Default::default()
1382        },
1383        Some(failure) => {
1384            tracing::warn!(
1385                path = pointer.path(),
1386                operation.label = operation
1387                    .label
1388                    .as_ref()
1389                    .and_then(|l| l.value.as_deref())
1390                    .unwrap_or("<no-label>"),
1391                operation.operation_type = get_operation_type(operation).unwrap_or("<unknown>"),
1392                error = %failure,
1393                "TestScript operation failed"
1394            );
1395
1396            TestReportSetupActionOperation {
1397                result: ReportActionResultCodes::warning(),
1398                message: Some(Box::new(FHIRMarkdown {
1399                    value: Some(format!("Operation failed: {failure}")),
1400                    ..Default::default()
1401                })),
1402                ..Default::default()
1403            }
1404        }
1405    };
1406
1407    Ok(TestResult {
1408        state: state.clone(),
1409        value,
1410    })
1411}
1412
1413fn get_source<'a>(
1414    state: &'a TestState,
1415    assertion: &TestScriptSetupActionAssert,
1416) -> Result<Option<&'a dyn MetaValue>, TestScriptError> {
1417    if let Some(source_id) = assertion.sourceId.as_ref().and_then(|id| id.value.as_ref()) {
1418        let source = state.resolve_fixture(source_id)?;
1419        Ok(Some(source))
1420    } else {
1421        match assertion
1422            .direction
1423            .as_ref()
1424            .unwrap_or(&AssertDirectionCodes::response())
1425        {
1426            assertion if assertion == &AssertDirectionCodes::request() => {
1427                if let Some(request) = state.latest_request.as_ref() {
1428                    request_to_meta_value(request)
1429                        .ok_or_else(|| TestScriptError::InvalidFixture)
1430                        .map(Some)
1431                } else {
1432                    Ok(None)
1433                }
1434            }
1435            assertion if assertion == &AssertDirectionCodes::response() => {
1436                if let Some(recorded) = state.latest_response.as_ref() {
1437                    response_to_meta_value(&recorded.response)
1438                        .ok_or_else(|| TestScriptError::InvalidFixture)
1439                        .map(Some)
1440                } else {
1441                    Ok(None)
1442                }
1443            }
1444            _ => Err(TestScriptError::ExecutionError(
1445                "Assert direction cannot be 'null' when sourceId is not provided.".to_string(),
1446            )),
1447        }
1448    }
1449}
1450
1451fn evaluate_operator(
1452    operator: &BoundCode<AssertOperatorCodes>,
1453    a: &Vec<conversion::ConvertedValue>,
1454    b: &Vec<conversion::ConvertedValue>,
1455) -> bool {
1456    match operator {
1457        operator
1458            if operator == &AssertOperatorCodes::equals()
1459                || operator == &AssertOperatorCodes::null() =>
1460        {
1461            a == b
1462        }
1463        operator if operator == &AssertOperatorCodes::not_equals() => !(a == b),
1464
1465        operator if operator == &AssertOperatorCodes::contains() => {
1466            if a.len() != 1 || b.len() != 1 {
1467                return false;
1468            }
1469
1470            match (&a[0], &b[0]) {
1471                (ConvertedValue::String(a_str), ConvertedValue::String(b_str)) => {
1472                    a_str.contains(b_str)
1473                }
1474                _ => false,
1475            }
1476        }
1477        operator if operator == &AssertOperatorCodes::empty() => {
1478            todo!("Empty operator not implemented")
1479        }
1480        operator if operator == &AssertOperatorCodes::eval() => {
1481            todo!("Eval operator not implemented")
1482        }
1483        operator if operator == &AssertOperatorCodes::greater_than() => {
1484            todo!("GreaterThan operator not implemented")
1485        }
1486        operator if operator == &AssertOperatorCodes::in_() => todo!("In operator not implemented"),
1487        operator if operator == &AssertOperatorCodes::less_than() => {
1488            todo!("LessThan operator not implemented")
1489        }
1490        operator if operator == &AssertOperatorCodes::not_contains() => {
1491            todo!("NotContains operator not implemented")
1492        }
1493        operator if operator == &AssertOperatorCodes::not_empty() => {
1494            todo!("NotEmpty operator not implemented")
1495        }
1496        operator if operator == &AssertOperatorCodes::not_in() => {
1497            todo!("NotIn operator not implemented")
1498        }
1499        _ => {
1500            todo!("Operator '{:?}' not implemented", operator)
1501        }
1502    }
1503    // a == b
1504}
1505
1506async fn derive_comparison_to(
1507    state: &TestState,
1508    assertion: &TestScriptSetupActionAssert,
1509) -> Result<Vec<ConvertedValue>, TestScriptError> {
1510    if let Some(comparision_fixture_id) = assertion
1511        .compareToSourceId
1512        .as_ref()
1513        .and_then(|c| c.value.as_ref())
1514    {
1515        let comparison_fixture = state.resolve_fixture(comparision_fixture_id)?;
1516
1517        let Some(comparison_expression) = assertion
1518            .compareToSourceExpression
1519            .as_ref()
1520            .and_then(|exp| exp.value.as_ref())
1521        else {
1522            return Err(TestScriptError::ExecutionError(
1523                "compareToSourceExpression is required when compareToSourceId is provided."
1524                    .to_string(),
1525            ));
1526        };
1527
1528        let result = state
1529            .fp_engine
1530            .evaluate(comparison_expression, vec![comparison_fixture])
1531            .await
1532            .map_err(|e| {
1533                TestScriptError::ExecutionError(format!(
1534                    "FHIRPath evaluation error for comparison fixture '{comparision_fixture_id}': {e}"
1535                ))
1536            })?;
1537
1538        Ok(result
1539            .iter()
1540            .map(conversion::convert_meta_value)
1541            .collect::<Vec<_>>())
1542    } else if let Some(value) = assertion.value.as_ref().and_then(|v| v.value.as_ref())
1543        && let Some(converted_value) = conversion::convert_string_value(value.as_ref())
1544    {
1545        Ok(vec![converted_value])
1546    } else {
1547        Err(TestScriptError::ExecutionError(
1548            "Failed to derive comparison value for assertion.".to_string(),
1549        ))
1550    }
1551}
1552
1553/// Assertions carry a `label`/`description` intended by the FHIR spec for
1554/// exactly this purpose: identifying an assertion in test engine output.
1555fn assert_label(assertion: &TestScriptSetupActionAssert) -> &str {
1556    assertion
1557        .label
1558        .as_ref()
1559        .and_then(|l| l.value.as_deref())
1560        .or_else(|| {
1561            assertion
1562                .description
1563                .as_ref()
1564                .and_then(|d| d.value.as_deref())
1565        })
1566        .unwrap_or("<no-label>")
1567}
1568
1569/// Assertions are what determine the testreports ultimate pass/fail status.
1570/// So set that within state here depending on assertion success/failure.
1571async fn run_assertion(
1572    state: Arc<Mutex<TestState>>,
1573    pointer: TypedPointer<TestScript, TestScriptSetupActionAssert>,
1574) -> Result<TestResult<TestReportSetupActionAssert>, TestScriptError> {
1575    let assertion = pointer.value().ok_or_else(|| {
1576        TestScriptError::ExecutionError(format!(
1577            "Failed to retrieve TestScript assertion at '{}'.",
1578            pointer.path()
1579        ))
1580    })?;
1581
1582    let mut state_guard = state.lock().await;
1583
1584    let default = AssertOperatorCodes::equals();
1585    let operator = assertion.operator.as_ref().unwrap_or(&default);
1586
1587    // Keep all uses of `source` inside this scope so the immutable borrow
1588    // of `state_guard` ends before we mutate `state_guard`.
1589    let failure_message = if let Some(message) =
1590        evaluate_http_assertion(&state_guard, assertion, pointer.path(), operator)?
1591    {
1592        Some(message)
1593    } else if assertion.resource.is_some() || assertion.expression.is_some() {
1594        let Some(source) = get_source(&state_guard, assertion)? else {
1595            return Err(TestScriptError::ExecutionError(format!(
1596                "Failed to resolve source for assertion at '{}'.",
1597                pointer.path()
1598            )));
1599        };
1600
1601        if let Some(message) =
1602            evaluate_resource_assertion(assertion, pointer.path(), source, operator)
1603        {
1604            Some(message)
1605        } else if let Some(expression) =
1606            assertion.expression.as_ref().and_then(|e| e.value.as_ref())
1607        {
1608            evaluate_expression_assertion(
1609                &state_guard,
1610                assertion,
1611                pointer.path(),
1612                source,
1613                operator,
1614                expression,
1615            )
1616            .await?
1617        } else {
1618            None
1619        }
1620    } else {
1621        None
1622    };
1623
1624    let (result, message) = match failure_message {
1625        Some(message) => {
1626            tracing::error!(
1627                assert.label = assert_label(assertion),
1628                path = pointer.path(),
1629                "{message}"
1630            );
1631
1632            state_guard.result = ReportResultCodes::fail();
1633
1634            (
1635                ReportActionResultCodes::fail(),
1636                Some(Box::new(FHIRMarkdown {
1637                    value: Some(message),
1638                    ..Default::default()
1639                })),
1640            )
1641        }
1642        None => (ReportActionResultCodes::pass(), None),
1643    };
1644
1645    Ok(TestResult {
1646        state: state.clone(),
1647        value: TestReportSetupActionAssert {
1648            result,
1649            message,
1650            ..Default::default()
1651        },
1652    })
1653}
1654
1655/// The status code an assertion's `response` code stands for.
1656fn response_code_status(code: &str) -> Option<u16> {
1657    Some(match code {
1658        "okay" => 200,
1659        "created" => 201,
1660        "noContent" => 204,
1661        "notModified" => 304,
1662        "bad" => 400,
1663        "forbidden" => 403,
1664        "notFound" => 404,
1665        "methodNotAllowed" => 405,
1666        "conflict" => 409,
1667        "gone" => 410,
1668        "preconditionFailed" => 412,
1669        "unprocessable" => 422,
1670        _ => return None,
1671    })
1672}
1673
1674/// Compares an HTTP value (a status code or a header, `None` when absent)
1675/// with an assertion's expected value under its operator, or `None` for an
1676/// operator that does not apply. `in` and `notIn` take a comma-separated list.
1677fn compare_http_value(operator: &str, actual: Option<&str>, expected: &str) -> Option<bool> {
1678    let listed = || {
1679        expected
1680            .split(',')
1681            .map(str::trim)
1682            .any(|item| Some(item) == actual)
1683    };
1684    let contains = actual.is_some_and(|actual| actual.contains(expected));
1685
1686    Some(match operator {
1687        "equals" => actual == Some(expected),
1688        "notEquals" => actual != Some(expected),
1689        "in" => listed(),
1690        "notIn" => !listed(),
1691        "contains" => contains,
1692        "notContains" => !contains,
1693        "empty" => actual.is_none_or(str::is_empty),
1694        "notEmpty" => actual.is_some_and(|actual| !actual.is_empty()),
1695        _ => return None,
1696    })
1697}
1698
1699/// An assertion's HTTP-level check, if it has one: `responseCode`,
1700/// `response`, `headerField` (against `value`) or `contentType`, made on the
1701/// response `sourceId` names or the latest one. An assertion holds at most one
1702/// check (invariants tst-5 and tst-6). Returns the failure.
1703fn evaluate_http_assertion(
1704    state: &TestState,
1705    assertion: &TestScriptSetupActionAssert,
1706    path: &str,
1707    operator: &BoundCode<AssertOperatorCodes>,
1708) -> Result<Option<String>, TestScriptError> {
1709    let response_code = assertion
1710        .responseCode
1711        .as_ref()
1712        .and_then(|code| code.value.as_deref());
1713    let response = assertion.response.as_ref().and_then(BoundCode::as_str);
1714    let header_field = assertion
1715        .headerField
1716        .as_ref()
1717        .and_then(|field| field.value.as_deref());
1718    let content_type = assertion
1719        .contentType
1720        .as_ref()
1721        .and_then(|content_type| content_type.value.as_deref());
1722
1723    if response_code.is_none()
1724        && response.is_none()
1725        && header_field.is_none()
1726        && content_type.is_none()
1727    {
1728        return Ok(None);
1729    }
1730
1731    let label = assert_label(assertion);
1732    let error = |message: String| {
1733        TestScriptError::ExecutionError(format!("Assertion '{label}' at '{path}': {message}"))
1734    };
1735    if assertion.direction.as_ref() == Some(&AssertDirectionCodes::request()) {
1736        return Err(error(
1737            "responseCode, response, headerField and contentType are checked against \
1738             responses only."
1739                .to_string(),
1740        ));
1741    }
1742
1743    let source_id = assertion
1744        .sourceId
1745        .as_ref()
1746        .and_then(|id| id.value.as_deref());
1747    let recorded = state.resolve_response(source_id)?;
1748    let Some(http) = recorded.http.as_ref() else {
1749        return Ok(Some(format!(
1750            "Assertion '{label}' at '{path}' failed: no HTTP response arrived."
1751        )));
1752    };
1753    let status = http.status.as_u16().to_string();
1754
1755    // What is checked, its actual value, and the expected one.
1756    let (what, actual, expected) = if let Some(code) = response_code {
1757        ("response code".to_string(), Some(status), code.to_string())
1758    } else if let Some(code) = response {
1759        let expected = response_code_status(code)
1760            .ok_or_else(|| error(format!("unknown response code '{code}'.")))?;
1761        (
1762            format!("response code (for '{code}')"),
1763            Some(status),
1764            expected.to_string(),
1765        )
1766    } else if let Some(field) = header_field {
1767        let expected = assertion
1768            .value
1769            .as_ref()
1770            .and_then(|value| value.value.as_deref())
1771            .unwrap_or_default();
1772        (
1773            format!("header '{field}'"),
1774            header_value(recorded, field),
1775            expected.to_string(),
1776        )
1777    } else {
1778        // The media type alone, without parameters such as `charset`.
1779        let actual = header_value(recorded, header::CONTENT_TYPE.as_str()).map(|value| {
1780            value
1781                .split(';')
1782                .next()
1783                .unwrap_or_default()
1784                .trim()
1785                .to_ascii_lowercase()
1786        });
1787        (
1788            "content type".to_string(),
1789            actual,
1790            content_type.unwrap_or_default().to_ascii_lowercase(),
1791        )
1792    };
1793
1794    let operator = operator.as_str().unwrap_or("equals");
1795    let passed = compare_http_value(operator, actual.as_deref(), &expected).ok_or_else(|| {
1796        error(format!(
1797            "operator '{operator}' is not supported for responseCode, response, headerField \
1798             or contentType."
1799        ))
1800    })?;
1801    if passed {
1802        return Ok(None);
1803    }
1804
1805    let actual = actual.map_or_else(|| "absent".to_string(), |actual| format!("'{actual}'"));
1806    let expected = match operator {
1807        "empty" | "notEmpty" => String::new(),
1808        _ => format!(" '{expected}'"),
1809    };
1810    Ok(Some(format!(
1811        "Assertion '{label}' at '{path}' failed: {what} was {actual}, expected {operator}{expected}."
1812    )))
1813}
1814
1815fn evaluate_resource_assertion(
1816    assertion: &TestScriptSetupActionAssert,
1817    path: &str,
1818    source: &dyn MetaValue,
1819    operator: &BoundCode<AssertOperatorCodes>,
1820) -> Option<String> {
1821    let resource_string = assertion.resource.as_ref()?.as_str()?;
1822
1823    let operation_evaluation_result = evaluate_operator(
1824        operator,
1825        &vec![conversion::ConvertedValue::String(
1826            resource_string.to_string(),
1827        )],
1828        &vec![conversion::ConvertedValue::String(
1829            source.fhir_type().to_string(),
1830        )],
1831    );
1832
1833    if operation_evaluation_result {
1834        return None;
1835    }
1836
1837    Some(format!(
1838        "Assertion '{}' at '{}' failed: resource type '{resource_string}' does not match '{}'.",
1839        assert_label(assertion),
1840        path,
1841        source.fhir_type()
1842    ))
1843}
1844
1845async fn evaluate_expression_assertion(
1846    state_guard: &TestState,
1847    assertion: &TestScriptSetupActionAssert,
1848    path: &str,
1849    source: &dyn MetaValue,
1850    operator: &BoundCode<AssertOperatorCodes>,
1851    expression: &str,
1852) -> Result<Option<String>, TestScriptError> {
1853    let comparison_to = derive_comparison_to(state_guard, assertion).await?;
1854
1855    let Ok(result) = state_guard
1856        .fp_engine
1857        .evaluate(expression, vec![source])
1858        .await
1859    else {
1860        tracing::error!(
1861            assert.label = assert_label(assertion),
1862            path = path,
1863            expression = expression,
1864            "Assertion '{}' at '{}' failed: FHIRPath expression '{expression}' failed to evaluate.",
1865            assert_label(assertion),
1866            path,
1867        );
1868
1869        return Err(TestScriptError::ExecutionError(format!(
1870            "Assertion '{}' at '{}': FHIRPath expression '{expression}' failed to evaluate.",
1871            assert_label(assertion),
1872            path,
1873        )));
1874    };
1875
1876    let converted_values = result
1877        .iter()
1878        .map(conversion::convert_meta_value)
1879        .collect::<Vec<_>>();
1880
1881    let operation_evaluation_result =
1882        evaluate_operator(operator, &converted_values, &comparison_to);
1883
1884    if operation_evaluation_result {
1885        return Ok(None);
1886    }
1887
1888    Ok(Some(format!(
1889        "Assertion '{}' at '{}' failed: '{converted_values:?}' {operator:?} '{comparison_to:?}'.",
1890        assert_label(assertion),
1891        path,
1892    )))
1893}
1894
1895async fn run_action<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
1896    client: &Client,
1897    ctx: CTX,
1898    state: Arc<Mutex<TestState>>,
1899    pointer: TypedPointer<TestScript, TestScriptTestAction>,
1900    options: Arc<TestRunnerOptions>,
1901) -> Result<TestResult<TestReportSetupAction>, TestScriptError> {
1902    tracing::info!("Running TestScript action at path: {}", pointer.path());
1903    let action = pointer.value().ok_or_else(|| {
1904        TestScriptError::ExecutionError(format!(
1905            "Failed to retrieve TestScript action at '{}'.",
1906            pointer.path()
1907        ))
1908    })?;
1909
1910    // Should be either an operation or an assert.
1911    // Both should not exist at the same time.
1912    if action.operation.is_some() {
1913        let Some(operation_pointer) =
1914            pointer.descend::<TestScriptSetupActionOperation>(&Key::Field("operation".to_string()))
1915        else {
1916            return Err(TestScriptError::ExecutionError(format!(
1917                "Failed to retrieve TestScript operation at '{}'.",
1918                pointer.path()
1919            )));
1920        };
1921
1922        let result = run_operation(client, ctx, state, operation_pointer, options).await?;
1923
1924        Ok(TestResult {
1925            state: result.state,
1926            value: TestReportSetupAction {
1927                operation: Some(result.value),
1928                ..Default::default()
1929            },
1930        })
1931    } else if action.assert.is_some() {
1932        let Some(assertion_pointer) =
1933            pointer.descend::<TestScriptSetupActionAssert>(&Key::Field("assert".to_string()))
1934        else {
1935            return Err(TestScriptError::ExecutionError(format!(
1936                "Failed to retrieve TestScript assertion at '{}'.",
1937                pointer.path()
1938            )));
1939        };
1940
1941        let assertion = run_assertion(state, assertion_pointer).await?;
1942
1943        Ok(TestResult {
1944            state: assertion.state,
1945            value: TestReportSetupAction {
1946                assert: Some(assertion.value),
1947                ..Default::default()
1948            },
1949        })
1950    } else {
1951        Err(TestScriptError::ExecutionError(format!(
1952            "TestScript action must have either an operation or an assert at '{}'.",
1953            pointer.path()
1954        )))
1955    }
1956}
1957
1958async fn run_setup_action<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
1959    client: &Client,
1960    ctx: CTX,
1961    state: Arc<Mutex<TestState>>,
1962    pointer: TypedPointer<TestScript, TestScriptSetupAction>,
1963    options: Arc<TestRunnerOptions>,
1964) -> Result<TestResult<TestReportSetupAction>, TestScriptError> {
1965    let action = pointer.value().ok_or_else(|| {
1966        TestScriptError::ExecutionError(format!(
1967            "Failed to retrieve TestScript action at '{}'.",
1968            pointer.path()
1969        ))
1970    })?;
1971
1972    tracing::info!("Running TestScript action at path: {}", pointer.path());
1973
1974    // Should be either an operation or an assert.
1975    // Both should not exist at the same time.
1976    if action.operation.is_some() {
1977        let Some(operation_pointer) =
1978            pointer.descend::<TestScriptSetupActionOperation>(&Key::Field("operation".to_string()))
1979        else {
1980            return Err(TestScriptError::ExecutionError(format!(
1981                "Failed to retrieve TestScript operation at '{}'.",
1982                pointer.path()
1983            )));
1984        };
1985
1986        let result = run_operation(client, ctx, state, operation_pointer, options).await?;
1987
1988        Ok(TestResult {
1989            state: result.state,
1990            value: TestReportSetupAction {
1991                operation: Some(result.value),
1992                ..Default::default()
1993            },
1994        })
1995    } else if action.assert.is_some() {
1996        let Some(assertion_pointer) =
1997            pointer.descend::<TestScriptSetupActionAssert>(&Key::Field("assert".to_string()))
1998        else {
1999            return Err(TestScriptError::ExecutionError(format!(
2000                "Failed to retrieve TestScript assertion at '{}'.",
2001                pointer.path()
2002            )));
2003        };
2004
2005        let assertion = run_assertion(state, assertion_pointer).await?;
2006
2007        Ok(TestResult {
2008            state: assertion.state,
2009            value: TestReportSetupAction {
2010                assert: Some(assertion.value),
2011                ..Default::default()
2012            },
2013        })
2014    } else {
2015        Err(TestScriptError::ExecutionError(format!(
2016            "TestScript action must have either an operation or an assert at '{}'.",
2017            pointer.path()
2018        )))
2019    }
2020}
2021
2022async fn setup_fixtures<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
2023    client: &Client,
2024    ctx: CTX,
2025    state: Arc<Mutex<TestState>>,
2026    pointer: TypedPointer<TestScript, TestScript>,
2027    _options: Arc<TestRunnerOptions>,
2028) -> Result<Arc<Mutex<TestState>>, OperationOutcomeError> {
2029    let mut state_lock = state.lock().await;
2030
2031    let Some(fixtures_pointer) =
2032        pointer.descend::<Vec<TestScriptFixture>>(&Key::Field("fixture".to_string()))
2033    else {
2034        return Ok(state.clone());
2035    };
2036
2037    let Some(fixtures) = fixtures_pointer.value() else {
2038        return Ok(state.clone());
2039    };
2040
2041    for fixture in fixtures {
2042        if let Some(reference_string) = fixture
2043            .resource
2044            .as_ref()
2045            .and_then(|r| r.reference.as_ref())
2046            .and_then(|refe| refe.value.as_ref())
2047        {
2048            let resolved_resource = if reference_string.starts_with('#')
2049                && let Some(contained) =
2050                    pointer.descend::<Vec<Resource>>(&Key::Field("contained".to_string()))
2051                && let Some(contained) = contained.value()
2052            {
2053                let local_id = &reference_string[1..];
2054                let Some(resource) = contained.iter().find(|res| {
2055                    if let Some(id) = res.get_field("id")
2056                        && let Some(id) = id.as_any().downcast_ref::<String>()
2057                    {
2058                        id.as_str() == local_id
2059                    } else {
2060                        false
2061                    }
2062                }) else {
2063                    return Err(OperationOutcomeError::error(
2064                        IssueType::not_found(),
2065                        format!("Contained resource with id '{local_id}' not found."),
2066                    ));
2067                };
2068
2069                resource.clone()
2070            } else {
2071                let parts = reference_string.split('/').collect::<Vec<&str>>();
2072                if parts.len() != 2 {
2073                    return Err(OperationOutcomeError::error(
2074                        IssueType::invalid(),
2075                        format!("Invalid fixture reference: {reference_string}"),
2076                    ));
2077                }
2078
2079                let resource_type = parts[0];
2080                let id = parts[1];
2081
2082                let Some(remote_resource) = client
2083                    .read(
2084                        ctx.clone(),
2085                        ResourceType::try_from(resource_type).map_err(|_| {
2086                            OperationOutcomeError::error(
2087                                IssueType::invalid(),
2088                                format!(
2089                                    "Invalid resource type in fixture reference: '{resource_type}'"
2090                                ),
2091                            )
2092                        })?,
2093                        id.to_string(),
2094                    )
2095                    .await?
2096                else {
2097                    return Err(OperationOutcomeError::error(
2098                        IssueType::not_found(),
2099                        format!("Resource '{resource_type}' with id '{id}' not found."),
2100                    ));
2101                };
2102
2103                remote_resource
2104            };
2105
2106            state_lock.fixtures.insert(
2107                fixture.id.clone().unwrap_or_default(),
2108                Fixtures::Resource(resolved_resource),
2109            );
2110        }
2111    }
2112
2113    drop(state_lock);
2114
2115    Ok(state)
2116}
2117
2118async fn run_setup<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
2119    client: &Client,
2120    ctx: CTX,
2121    state: Arc<Mutex<TestState>>,
2122    pointer: TypedPointer<TestScript, TestScriptSetup>,
2123    options: Arc<TestRunnerOptions>,
2124) -> Result<TestResult<TestReportSetup>, TestScriptError> {
2125    let mut cur_state = state;
2126
2127    let mut setup_results = TestReportSetup {
2128        action: vec![],
2129        ..Default::default()
2130    };
2131
2132    let Some(setup) = pointer.value() else {
2133        return Ok(TestResult {
2134            state: cur_state,
2135            value: setup_results,
2136        });
2137    };
2138
2139    for action in setup.action.iter().enumerate() {
2140        let action_pointer = pointer
2141            .descend::<Vec<TestScriptSetupAction>>(&Key::Field("action".to_string()))
2142            .and_then(|p| p.descend::<TestScriptSetupAction>(&Key::Index(action.0)));
2143
2144        let action_pointer = action_pointer.ok_or_else(|| {
2145            TestScriptError::ExecutionError(format!(
2146                "Failed to retrieve TestScript action at index {}.",
2147                action.0
2148            ))
2149        })?;
2150
2151        let result = run_setup_action(
2152            client,
2153            ctx.clone(),
2154            cur_state,
2155            action_pointer,
2156            options.clone(),
2157        )
2158        .await?;
2159        cur_state = result.state;
2160
2161        setup_results.action.push(result.value);
2162    }
2163
2164    Ok(TestResult {
2165        state: cur_state,
2166        value: setup_results,
2167    })
2168}
2169
2170async fn run_teardown<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
2171    client: &Client,
2172    ctx: CTX,
2173    state: Arc<Mutex<TestState>>,
2174    pointer: TypedPointer<TestScript, TestScriptTeardown>,
2175    options: Arc<TestRunnerOptions>,
2176) -> Result<TestResult<TestReportTeardown>, TestScriptError> {
2177    let mut cur_state = state;
2178
2179    let mut teardown_results = TestReportTeardown {
2180        action: vec![],
2181        ..Default::default()
2182    };
2183
2184    let Some(actions) = pointer.value() else {
2185        return Ok(TestResult {
2186            state: cur_state,
2187            value: teardown_results,
2188        });
2189    };
2190
2191    for action in actions.action.iter().enumerate() {
2192        let action_pointer = pointer
2193            .descend::<Vec<TestScriptTeardownAction>>(&Key::Field("action".to_string()))
2194            .and_then(|p| p.descend::<TestScriptTeardownAction>(&Key::Index(action.0)));
2195
2196        let action_pointer = action_pointer.ok_or_else(|| {
2197            TestScriptError::ExecutionError(format!(
2198                "Failed to retrieve TestScript teardown action at index {}.",
2199                action.0
2200            ))
2201        })?;
2202
2203        let operation_pointer = action_pointer
2204            .descend::<TestScriptSetupActionOperation>(&Key::Field("operation".to_string()))
2205            .ok_or_else(|| {
2206                TestScriptError::ExecutionError(format!(
2207                    "Failed to retrieve TestScript teardown operation at index {}.",
2208                    action.0
2209                ))
2210            })?;
2211
2212        let result = run_operation(
2213            client,
2214            ctx.clone(),
2215            cur_state,
2216            operation_pointer,
2217            options.clone(),
2218        )
2219        .await?;
2220        cur_state = result.state;
2221
2222        teardown_results.action.push(TestReportTeardownAction {
2223            operation: result.value,
2224            ..Default::default()
2225        });
2226    }
2227
2228    Ok(TestResult {
2229        state: cur_state,
2230        value: teardown_results,
2231    })
2232}
2233
2234async fn run_test<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
2235    client: &Client,
2236    ctx: CTX,
2237    state: Arc<Mutex<TestState>>,
2238    pointer: TypedPointer<TestScript, TestScriptTest>,
2239    options: Arc<TestRunnerOptions>,
2240) -> Result<TestResult<TestReportTest>, TestScriptError> {
2241    let mut cur_state = state;
2242    let mut test_report_test = TestReportTest {
2243        action: vec![],
2244        ..Default::default()
2245    };
2246
2247    let test = pointer.value().ok_or_else(|| {
2248        TestScriptError::ExecutionError(format!(
2249            "Failed to retrieve TestScript test at '{}'.",
2250            pointer.path()
2251        ))
2252    })?;
2253
2254    for action in test.action.iter().enumerate() {
2255        let Some(action_pointer) = pointer
2256            .descend::<Vec<TestScriptTestAction>>(&Key::Field("action".to_string()))
2257            .and_then(|p| p.descend(&Key::Index(action.0)))
2258        else {
2259            return Err(TestScriptError::ExecutionError(format!(
2260                "Failed to retrieve TestScript test action at index {}.",
2261                action.0
2262            )));
2263        };
2264        let state = cur_state.clone();
2265        let result = run_action(
2266            client,
2267            ctx.clone(),
2268            cur_state,
2269            action_pointer.clone(),
2270            options.clone(),
2271        )
2272        .await;
2273        let result = match result {
2274            Ok(result) => result,
2275            // An action that cannot run (a read of a fixture the failed create
2276            // before it never produced, say) fails its test instead of ending
2277            // the run with no report: the error becomes the action's result
2278            // and the test's remaining actions are skipped.
2279            Err(TestScriptError::ExecutionError(message)) => {
2280                tracing::error!(
2281                    path = action_pointer.path(),
2282                    "TestScript action could not run: {message}"
2283                );
2284                state.lock().await.result = ReportResultCodes::fail();
2285                test_report_test.action.push(TestReportTestAction {
2286                    operation: Some(TestReportSetupActionOperation {
2287                        result: ReportActionResultCodes::error(),
2288                        message: Some(Box::new(FHIRMarkdown {
2289                            value: Some(message),
2290                            ..Default::default()
2291                        })),
2292                        ..Default::default()
2293                    }),
2294                    ..Default::default()
2295                });
2296                return Ok(TestResult {
2297                    state,
2298                    value: test_report_test,
2299                });
2300            }
2301            Err(e) => return Err(e),
2302        };
2303        cur_state = result.state;
2304        test_report_test.action.push(TestReportTestAction {
2305            operation: result.value.operation,
2306            assert: result.value.assert,
2307            ..Default::default()
2308        });
2309    }
2310
2311    Ok(TestResult {
2312        state: cur_state,
2313        value: test_report_test,
2314    })
2315}
2316
2317async fn run_tests<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
2318    client: &Client,
2319    ctx: CTX,
2320    state: Arc<Mutex<TestState>>,
2321    pointer: TypedPointer<TestScript, Vec<TestScriptTest>>,
2322    options: Arc<TestRunnerOptions>,
2323) -> Result<TestResult<Vec<TestReportTest>>, TestScriptError> {
2324    let mut test_results = vec![];
2325    let mut cur_state = state;
2326
2327    let Some(tests) = pointer.value() else {
2328        return Ok(TestResult {
2329            state: cur_state,
2330            value: test_results,
2331        });
2332    };
2333
2334    for test in tests.iter().enumerate() {
2335        let Some(test_pointer) = pointer.descend(&Key::Index(test.0)) else {
2336            return Err(TestScriptError::ExecutionError(format!(
2337                "Failed to retrieve TestScript test at index {}.",
2338                test.0
2339            )));
2340        };
2341        let test_result = run_test(
2342            client,
2343            ctx.clone(),
2344            cur_state,
2345            test_pointer,
2346            options.clone(),
2347        )
2348        .await?;
2349        cur_state = test_result.state;
2350        test_results.push(test_result.value);
2351    }
2352
2353    Ok(TestResult {
2354        state: cur_state,
2355        value: test_results,
2356    })
2357}
2358
2359pub struct TestRunnerOptions {
2360    pub wait_between_operations: Option<Duration>,
2361    /// Before a request that reads the search index (a search, an operation,
2362    /// a conditional delete or a conditional update), wait up to this long
2363    /// for the latest writes to be indexed. Waits only as long as indexing
2364    /// takes, where `wait_between_operations` waits a fixed time after every
2365    /// operation.
2366    pub index_wait_timeout: Option<Duration>,
2367}
2368
2369/// Runs a FHIR `TestScript` using the provided client and execution context.
2370///
2371/// This executes the `TestScript` lifecycle:
2372/// - fixture setup
2373/// - setup actions
2374/// - test actions
2375/// - teardown actions
2376///
2377/// # Errors
2378///
2379/// Returns [`TestScriptError`] if:
2380/// - fixture setup fails
2381/// - setup actions fail
2382/// - test execution fails
2383/// - teardown execution fails
2384/// - an operation performed by the FHIR client fails
2385#[tracing::instrument(
2386    name = "testscript_run",
2387    skip_all,
2388    fields(
2389        testscript.id = test_script.id.as_deref().unwrap_or("<no-id>"),
2390        testscript.name = test_script.name.value.as_deref().unwrap_or("<unnamed>"),
2391        testscript.url = test_script.url.value.as_deref().unwrap_or("<no-url>"),
2392    )
2393)]
2394pub async fn run<CTX: Clone + WithRequestHeaders, Client: FHIRHttpExchange<CTX>>(
2395    client: &Client,
2396    ctx: CTX,
2397    test_script: Arc<TestScript>,
2398    options: Arc<TestRunnerOptions>,
2399) -> Result<TestReport, TestScriptError> {
2400    tracing::info!("Starting TestScript run");
2401
2402    let mut test_report = TestReport {
2403        status: ReportStatusCodes::completed(),
2404        testScript: Box::new(Reference {
2405            reference: Some(Box::new(FHIRString {
2406                value: Some(format!(
2407                    "Testscript/{}",
2408                    test_script.id.clone().unwrap_or_default()
2409                )),
2410                ..Default::default()
2411            })),
2412            ..Default::default()
2413        }),
2414        ..Default::default()
2415    };
2416
2417    let mut state = Arc::new(Mutex::new(TestState::new()));
2418    let pointer = TypedPointer::<TestScript, TestScript>::new(test_script);
2419
2420    state = setup_fixtures(client, ctx.clone(), state, pointer.clone(), options.clone())
2421        .await
2422        .map_err(TestScriptError::OperationError)?;
2423
2424    let mut running_state = Ok(());
2425
2426    // Run setup actions
2427    if let Some(setup_pointer) =
2428        pointer.descend::<TestScriptSetup>(&Key::Field("setup".to_string()))
2429    {
2430        tracing::info!("Running TestScript setup...");
2431        let setup_result = run_setup(
2432            client,
2433            ctx.clone(),
2434            state.clone(),
2435            setup_pointer,
2436            options.clone(),
2437        )
2438        .await;
2439        match setup_result {
2440            Ok(res) => {
2441                state = res.state;
2442                test_report.setup = Some(res.value);
2443            }
2444            Err(e) => {
2445                tracing::error!(phase = "setup", error = ?e, "TestScript run failed during setup");
2446                running_state = Err(e);
2447            }
2448        }
2449    }
2450
2451    // Run Test actions
2452    if running_state.is_ok()
2453        && let Some(test_pointer) =
2454            pointer.descend::<Vec<TestScriptTest>>(&Key::Field("test".to_string()))
2455    {
2456        tracing::info!("Running TestScript tests...");
2457        let test_result = run_tests(
2458            client,
2459            ctx.clone(),
2460            state.clone(),
2461            test_pointer,
2462            options.clone(),
2463        )
2464        .await;
2465
2466        match test_result {
2467            Ok(res) => {
2468                state = res.state;
2469                test_report.test = Some(res.value);
2470            }
2471
2472            Err(e) => {
2473                tracing::error!(phase = "test", error = ?e, "TestScript run failed during test execution");
2474                running_state = Err(e);
2475            }
2476        }
2477    }
2478
2479    if let Some(teardown_pointer) =
2480        pointer.descend::<TestScriptTeardown>(&Key::Field("teardown".to_string()))
2481    {
2482        tracing::info!("Running TestScript teardown...");
2483
2484        let result = run_teardown(
2485            client,
2486            ctx.clone(),
2487            state.clone(),
2488            teardown_pointer,
2489            options.clone(),
2490        )
2491        .await?;
2492
2493        // state = result.state;
2494        test_report.teardown = Some(result.value);
2495    }
2496
2497    running_state?;
2498
2499    let state_guard = state.lock().await;
2500    // Only set result to fail so if still pending can assume pass.
2501    // Flip to fail in assertion tests if any fail.
2502    match &state_guard.result {
2503        state if state == &ReportResultCodes::pending() => {
2504            test_report.result = ReportResultCodes::pass();
2505        }
2506        status => test_report.result = status.clone(),
2507    }
2508
2509    if test_report.result == ReportResultCodes::fail() {
2510        tracing::error!(result = ?test_report.result, "TestScript run finished with failures");
2511    } else {
2512        tracing::info!(result = ?test_report.result, "TestScript run finished");
2513    }
2514
2515    Ok(test_report)
2516}
2517
2518#[cfg(test)]
2519mod tests {
2520    use super::*;
2521    use haste_fhir_client::request::{FHIRCreateResponse, FHIRDeleteInstanceResponse};
2522
2523    fn patient(id: &str, version_id: &str) -> Resource {
2524        serde_json::from_value(serde_json::json!({
2525            "resourceType": "Patient",
2526            "id": id,
2527            "meta": { "versionId": version_id }
2528        }))
2529        .expect("valid Patient")
2530    }
2531
2532    #[test]
2533    fn writes_record_the_version_search_must_return() {
2534        let created = FHIRResponse::Create(FHIRCreateResponse {
2535            resource: patient("p1", "v1"),
2536        });
2537        let write = indexed_write(&created).expect("a create is a write");
2538        assert_eq!(write.resource_type, ResourceType::Patient);
2539        assert_eq!(write.id, "p1");
2540        assert_eq!(write.version_id.as_deref(), Some("v1"));
2541
2542        // A delete must be gone from search, whatever version it was.
2543        let deleted = FHIRResponse::Delete(DeleteResponse::Instance(Box::new(
2544            FHIRDeleteInstanceResponse {
2545                resource: patient("p1", "v2"),
2546            },
2547        )));
2548        assert_eq!(
2549            indexed_write(&deleted)
2550                .expect("a delete is a write")
2551                .version_id,
2552            None
2553        );
2554    }
2555
2556    #[test]
2557    fn only_index_backed_requests_wait() {
2558        let search = FHIRRequest::Search(SearchRequest::Type(FHIRSearchTypeRequest {
2559            resource_type: ResourceType::Patient,
2560            parameters: ParsedParameters::try_from("_tag=x").unwrap(),
2561        }));
2562        assert!(reads_search_index(&search));
2563
2564        let read = FHIRRequest::Read(FHIRReadRequest {
2565            resource_type: ResourceType::Patient,
2566            id: "p1".to_string(),
2567        });
2568        assert!(!reads_search_index(&read), "a read goes to the repository");
2569    }
2570}