1/*
   2 * SPDX-FileCopyrightText: 2025 Copyright (c) Contributors to the Eclipse Foundation
   3 *
   4 * See the NOTICE file(s) distributed with this work for additional
   5 * information regarding copyright ownership.
   6 *
   7 * This program and the accompanying materials are made available under the
   8 * terms of the Apache License Version 2.0 which is available at
   9 * https://www.apache.org/licenses/LICENSE-2.0
  10 *
  11 * SPDX-License-Identifier: Apache-2.0
  12 */
  13
  14use aide::{UseApi, transform::TransformOperation};
  15use axum::{
  16    Json,
  17    extract::{Query, State},
  18    http::StatusCode,
  19    response::{IntoResponse as _, Response},
  20};
  21use axum_extra::extract::WithRejection;
  22use cda_interfaces::{SchemaProvider, UdsEcu, file_manager::FileManager};
  23use cda_plugin_security::Secured;
  24use sovd_interfaces::components::ecu::operations::OperationCollectionItem;
  25
  26use crate::sovd::{
  27    WebserverEcuState, create_schema,
  28    error::{ApiError, ErrorWrapper},
  29};
  30
  31pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
  32    UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
  33    WithRejection(Query(query), _): WithRejection<
  34        Query<sovd_interfaces::IncludeSchemaQuery>,
  35        ApiError,
  36    >,
  37    State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
  38) -> Response {
  39    use cda_interfaces::DynamicPlugin;
  40    let security_plugin: DynamicPlugin = security_plugin;
  41    match uds
  42        .get_components_operations_info(&ecu_name, &security_plugin)
  43        .await
  44    {
  45        Ok(items) => {
  46            let schema = if query.include_schema {
  47                Some(create_schema!(
  48                    sovd_interfaces::Items<OperationCollectionItem>
  49                ))
  50            } else {
  51                None
  52            };
  53            (
  54                StatusCode::OK,
  55                Json(sovd_interfaces::Items {
  56                    items: items
  57                        .into_iter()
  58                        .map(|info| OperationCollectionItem {
  59                            id: info.id,
  60                            name: info.name,
  61                            proximity_proof_required: false,
  62                            asynchronous_execution: info.has_stop || info.has_request_results,
  63                        })
  64                        .collect(),
  65                    schema,
  66                }),
  67            )
  68                .into_response()
  69        }
  70        Err(e) => ErrorWrapper {
  71            error: e.into(),
  72            include_schema: query.include_schema,
  73        }
  74        .into_response(),
  75    }
  76}
  77
  78pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
  79    op.description("Get all available operations for this ECU component")
  80        .response_with::<200, Json<sovd_interfaces::Items<OperationCollectionItem>>, _>(|res| {
  81            res.description("List of operations available on this ECU.")
  82        })
  83}
  84
  85pub(crate) mod comparams {
  86
  87    pub(crate) mod executions {
  88        use std::sync::Arc;
  89
  90        use aide::{UseApi, transform::TransformOperation};
  91        use axum::{
  92            Json,
  93            extract::{OriginalUri, Path, Query, State},
  94            http::{StatusCode, header},
  95            response::{IntoResponse as _, Response},
  96        };
  97        use axum_extra::extract::WithRejection;
  98        use cda_interfaces::{HashMap, HashMapExtensions, UdsEcu, file_manager::FileManager};
  99        use indexmap::IndexMap;
 100        use opensovd_axum_extra::ExtractHost;
 101        use sovd_interfaces::components::ecu::operations::comparams as sovd_comparams;
 102        use tokio::sync::RwLock;
 103        use uuid::Uuid;
 104
 105        use crate::sovd::{
 106            IntoSovd, WebserverEcuState, create_schema,
 107            error::{ApiError, ErrorWrapper},
 108        };
 109
 110        fn parse_exec_uuid(id: &str, include_schema: bool) -> Result<Uuid, ErrorWrapper> {
 111            Uuid::parse_str(id).map_err(|e| ErrorWrapper {
 112                error: ApiError::BadRequest(format!("{e:?}")),
 113                include_schema,
 114            })
 115        }
 116
 117        pub(crate) async fn get<T: UdsEcu + Clone, U: FileManager>(
 118            WithRejection(Query(query), _): WithRejection<
 119                Query<sovd_comparams::executions::get::Query>,
 120                ApiError,
 121            >,
 122            State(WebserverEcuState {
 123                comparam_executions,
 124                ..
 125            }): State<WebserverEcuState<T, U>>,
 126        ) -> Response {
 127            handler_read(comparam_executions, query.include_schema).await
 128        }
 129
 130        pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
 131            op.description("Get all comparam executions")
 132                .response_with::<200, Json<sovd_comparams::executions::get::Response>, _>(|res| {
 133                    res.description("Response with all comparam executions.")
 134                        .example(sovd_comparams::executions::get::Response {
 135                            items: vec![sovd_comparams::executions::Item {
 136                                id: "b7e2c1a2-3f4d-4e6a-9c8b-2a1d5e7f8c9b".to_string(),
 137                            }],
 138                            schema: None,
 139                        })
 140                })
 141        }
 142
 143        pub(crate) async fn post<T: UdsEcu + Clone, U: FileManager>(
 144            WithRejection(Query(query), _): WithRejection<
 145                Query<sovd_comparams::executions::get::Query>,
 146                ApiError,
 147            >,
 148            State(WebserverEcuState {
 149                comparam_executions,
 150                update_in_progress,
 151                ..
 152            }): State<WebserverEcuState<T, U>>,
 153            UseApi(ExtractHost(host), _): UseApi<ExtractHost, String>,
 154            OriginalUri(uri): OriginalUri,
 155            request_body: Option<Json<sovd_comparams::executions::update::Request>>,
 156        ) -> Response {
 157            let path = format!("http://{host}{uri}");
 158            let body = if let Some(Json(body)) = request_body {
 159                Some(body)
 160            } else {
 161                None
 162            };
 163            handler_write(
 164                comparam_executions,
 165                path,
 166                body,
 167                query.include_schema,
 168                &update_in_progress,
 169            )
 170            .await
 171        }
 172
 173        pub(crate) fn docs_post(op: TransformOperation) -> TransformOperation {
 174            op.description("Create a new comparam execution")
 175                .response_with::<202, Json<sovd_comparams::executions::update::Response>, _>(
 176                    |res| {
 177                        res.description("Comparam execution created successfully.")
 178                            .example(sovd_comparams::executions::update::Response {
 179                                id: "b7e2c1a2-3f4d-4e6a-9c8b-2a1d5e7f8c9b".to_string(),
 180                                status: sovd_comparams::executions::Status::Running,
 181                                schema: None,
 182                            })
 183                    },
 184                )
 185        }
 186
 187        pub(crate) async fn handler_read(
 188            executions: Arc<RwLock<IndexMap<Uuid, sovd_comparams::Execution>>>,
 189            include_schema: bool,
 190        ) -> Response {
 191            let schema = if include_schema {
 192                Some(create_schema!(sovd_comparams::executions::get::Response))
 193            } else {
 194                None
 195            };
 196            (
 197                StatusCode::OK,
 198                Json(sovd_comparams::executions::get::Response {
 199                    items: executions
 200                        .read()
 201                        .await
 202                        .keys()
 203                        .map(|id| sovd_comparams::executions::Item { id: id.to_string() })
 204                        .collect::<Vec<_>>(),
 205                    schema,
 206                }),
 207            )
 208                .into_response()
 209        }
 210        async fn handler_write(
 211            executions: Arc<RwLock<IndexMap<Uuid, sovd_comparams::Execution>>>,
 212            base_path: String,
 213            request: Option<sovd_comparams::executions::update::Request>,
 214            include_schema: bool,
 215            update_in_progress: &std::sync::atomic::AtomicBool,
 216        ) -> Response {
 217            // todo: not in scope for now: request can take body with
 218            // { timeout: INT, parameters: { ... }, proximity_response: STRING }
 219            let mut executions = executions.write().await;
 220            if update_in_progress.load(std::sync::atomic::Ordering::Acquire) {
 221                return ErrorWrapper {
 222                    error: ApiError::Conflict(
 223                        "Runtime update in progress, operation blocked".to_owned(),
 224                    ),
 225                    include_schema,
 226                }
 227                .into_response();
 228            }
 229            let id = Uuid::new_v4();
 230            let mut comparam_override: HashMap<String, sovd_comparams::ComParamValue> =
 231                HashMap::new();
 232
 233            if let Some(sovd_comparams::executions::update::Request {
 234                parameters: Some(parameters),
 235                ..
 236            }) = request
 237            {
 238                for (k, v) in parameters {
 239                    comparam_override.insert(k, v);
 240                }
 241            }
 242
 243            let schema = if include_schema {
 244                Some(create_schema!(sovd_comparams::executions::update::Response))
 245            } else {
 246                None
 247            };
 248
 249            let create_execution_response = sovd_comparams::executions::update::Response {
 250                id: id.to_string(),
 251                status: sovd_comparams::executions::Status::Running,
 252                schema,
 253            };
 254            executions.insert(
 255                id,
 256                sovd_comparams::Execution {
 257                    capability: sovd_comparams::executions::Capability::Execute,
 258                    status: create_execution_response.status.clone(),
 259                    comparam_override,
 260                },
 261            );
 262            (
 263                StatusCode::ACCEPTED,
 264                [(header::LOCATION, format!("{base_path}/{id}"))],
 265                Json(create_execution_response),
 266            )
 267                .into_response()
 268        }
 269
 270        pub(crate) mod id {
 271            use super::*;
 272            use crate::{openapi, sovd::components::IdPathParam};
 273            pub(crate) async fn get<T: UdsEcu + Clone, U: FileManager>(
 274                Path(id): Path<IdPathParam>,
 275                WithRejection(Query(query), _): WithRejection<
 276                    Query<sovd_comparams::executions::get::Query>,
 277                    ApiError,
 278                >,
 279                State(WebserverEcuState {
 280                    ecu_name,
 281                    uds,
 282                    comparam_executions,
 283                    ..
 284                }): State<WebserverEcuState<T, U>>,
 285            ) -> Response {
 286                let include_schema = query.include_schema;
 287                let id = match parse_exec_uuid(&id, include_schema) {
 288                    Ok(v) => v,
 289                    Err(e) => return e.into_response(),
 290                };
 291                let mut executions: Vec<sovd_comparams::Execution> = Vec::new();
 292
 293                let (idx, execution) = match comparam_executions
 294                    .read()
 295                    .await
 296                    .get_full(&id)
 297                    .ok_or_else(|| {
 298                        ApiError::NotFound(Some(format!("Execution with id {id} not found")))
 299                    }) {
 300                    Ok((idx, _, v)) => (idx, v.clone()),
 301                    Err(e) => {
 302                        return ErrorWrapper {
 303                            error: e,
 304                            include_schema,
 305                        }
 306                        .into_response();
 307                    }
 308                };
 309                let capability = execution.capability.clone();
 310                let status = execution.status.clone();
 311
 312                // put in all executions with lower index than this one
 313                for (_, v) in &comparam_executions.read().await.as_slice()[..idx] {
 314                    executions.push(v.clone());
 315                }
 316                executions.push(execution);
 317
 318                let mut parameters = match uds.get_comparams(&ecu_name).await {
 319                    Ok(v) => v.into_sovd(),
 320                    Err(e) => {
 321                        return ErrorWrapper {
 322                            error: e.into(),
 323                            include_schema,
 324                        }
 325                        .into_response();
 326                    }
 327                };
 328
 329                for (k, v) in executions.into_iter().flat_map(|e| e.comparam_override) {
 330                    parameters.insert(k, v);
 331                }
 332
 333                let schema = if include_schema {
 334                    Some(create_schema!(
 335                        sovd_comparams::executions::id::get::Response
 336                    ))
 337                } else {
 338                    None
 339                };
 340
 341                (
 342                    StatusCode::OK,
 343                    Json(sovd_comparams::executions::id::get::Response {
 344                        capability,
 345                        parameters,
 346                        status,
 347                        schema,
 348                    }),
 349                )
 350                    .into_response()
 351            }
 352
 353            pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
 354                op.description("Get a specific comparam execution")
 355                    .response_with::<200, Json<sovd_comparams::executions::id::get::Response>, _>(
 356                        |res| {
 357                            res.description("Response with comparam execution details.")
 358                                .example(sovd_comparams::executions::id::get::Response {
 359                                    capability: sovd_comparams::executions::Capability::Execute,
 360                                    parameters: HashMap::new(),
 361                                    status: sovd_comparams::executions::Status::Running,
 362                                    schema: None,
 363                                })
 364                        },
 365                    )
 366                    .with(openapi::comparam_execution_errors)
 367            }
 368
 369            pub(crate) async fn delete<T: UdsEcu + Clone, U: FileManager>(
 370                Path(id): Path<IdPathParam>,
 371                State(WebserverEcuState {
 372                    comparam_executions,
 373                    ..
 374                }): State<WebserverEcuState<T, U>>,
 375            ) -> Response {
 376                let id = match parse_exec_uuid(&id, false) {
 377                    Ok(v) => v,
 378                    Err(e) => return e.into_response(),
 379                };
 380                let mut executions = comparam_executions.write().await;
 381                if executions.shift_remove(&id).is_none() {
 382                    return ErrorWrapper {
 383                        error: ApiError::NotFound(Some(format!(
 384                            "Execution with id {id} not found"
 385                        ))),
 386                        include_schema: false,
 387                    }
 388                    .into_response();
 389                }
 390                StatusCode::NO_CONTENT.into_response()
 391            }
 392
 393            pub(crate) fn docs_delete(op: TransformOperation) -> TransformOperation {
 394                op.description("Delete a specific comparam execution")
 395                    .response_with::<204, (), _>(|res| {
 396                        res.description("Comparam execution deleted successfully.")
 397                    })
 398                    .with(openapi::comparam_execution_errors)
 399            }
 400
 401            pub(crate) async fn put<T: UdsEcu + Clone, U: FileManager>(
 402                Path(id): Path<IdPathParam>,
 403                WithRejection(Query(query), _): WithRejection<
 404                    Query<sovd_comparams::executions::update::Query>,
 405                    ApiError,
 406                >,
 407                State(WebserverEcuState {
 408                    comparam_executions,
 409                    ..
 410                }): State<WebserverEcuState<T, U>>,
 411                UseApi(ExtractHost(host), _): UseApi<ExtractHost, String>,
 412                OriginalUri(uri): OriginalUri,
 413                WithRejection(Json(request), _): WithRejection<
 414                    Json<sovd_comparams::executions::update::Request>,
 415                    ApiError,
 416                >,
 417            ) -> Response {
 418                let include_schema = query.include_schema;
 419                let id = match parse_exec_uuid(&id, include_schema) {
 420                    Ok(v) => v,
 421                    Err(e) => return e.into_response(),
 422                };
 423                let path = format!("http://{host}{uri}");
 424                // todo: (out of scope for now) handle timout and capability
 425
 426                // todo: validate that the passed in CP is actually a valid CP for the ECU
 427                // let mut comparams = match uds.get_comparams(&ecu_name).await {
 428                //     Ok(v) => v,
 429                //     Err(e) => return ErrorWrapper(ApiError::BadRequest(e)).into_response(),
 430                // };
 431
 432                let mut executions_lock = comparam_executions.write().await;
 433                let execution: &mut sovd_comparams::Execution =
 434                    match executions_lock.get_mut(&id).ok_or_else(|| {
 435                        ApiError::NotFound(Some(format!("Execution with id {id} not found")))
 436                    }) {
 437                        Ok(v) => v,
 438                        Err(e) => {
 439                            return ErrorWrapper {
 440                                error: e,
 441                                include_schema,
 442                            }
 443                            .into_response();
 444                        }
 445                    };
 446
 447                if let Some(comparam_values) = request.parameters {
 448                    for (k, v) in comparam_values {
 449                        execution.comparam_override.insert(k, v);
 450                    }
 451                }
 452
 453                let schema = if include_schema {
 454                    Some(create_schema!(sovd_comparams::executions::update::Response))
 455                } else {
 456                    None
 457                };
 458
 459                (
 460                    StatusCode::ACCEPTED,
 461                    [(header::LOCATION, path)],
 462                    Json(sovd_comparams::executions::update::Response {
 463                        id: id.to_string(),
 464                        status: execution.status.clone(),
 465                        schema,
 466                    }),
 467                )
 468                    .into_response()
 469            }
 470
 471            pub(crate) fn docs_put(op: TransformOperation) -> TransformOperation {
 472                op.description("Update a specific comparam execution")
 473                    .response_with::<202, Json<sovd_comparams::executions::update::Response>, _>(
 474                        |res| {
 475                            res.description("Comparam execution updated successfully.")
 476                                .example(sovd_comparams::executions::update::Response {
 477                                    id: "example_id".to_string(),
 478                                    status: sovd_comparams::executions::Status::Running,
 479                                    schema: None,
 480                                })
 481                        },
 482                    )
 483                    .with(openapi::comparam_execution_errors)
 484            }
 485        }
 486    }
 487}
 488
 489pub(crate) mod service {
 490    /// `GET /operations/{service}` - get operation details or SDGs
[docs] 491    // [[ dimpl~sovd-api-component-operations-sdgsd, GET /operations/{service} SDG handler ]]
 492    pub(crate) async fn get<
 493        T: cda_interfaces::UdsEcu + cda_interfaces::SchemaProvider + Clone,
 494        U: cda_interfaces::file_manager::FileManager,
 495    >(
 496        aide::UseApi(cda_plugin_security::Secured(security_plugin), _): aide::UseApi<
 497            cda_plugin_security::Secured,
 498            (),
 499        >,
 500        axum::extract::Path(docs_endpoint::OperationNamePathParam { service }): axum::extract::Path<
 501            docs_endpoint::OperationNamePathParam,
 502        >,
 503        axum_extra::extract::WithRejection(axum::extract::Query(query), _): axum_extra::extract::WithRejection<
 504            axum::extract::Query<sovd_interfaces::components::ComponentQuery>,
 505            crate::sovd::error::ApiError,
 506        >,
 507        axum::extract::State(crate::sovd::WebserverEcuState { ecu_name, uds, .. }): axum::extract::State<
 508            crate::sovd::WebserverEcuState<T, U>,
 509        >,
 510    ) -> axum::response::Response {
 511        use axum::response::IntoResponse as _;
 512
 513        let include_schema = query.include_schema;
 514        if query.include_sdgs {
 515            return get_sdgs_handler::<T>(service, &ecu_name, &uds, include_schema).await;
 516        }
 517
 518        // Normal response: return operation info for the named service
 519        let security_plugin: cda_interfaces::DynamicPlugin = security_plugin;
 520        let ops_info = match uds
 521            .get_components_operations_info(&ecu_name, &security_plugin)
 522            .await
 523        {
 524            Ok(info) => info,
 525            Err(e) => {
 526                return crate::sovd::error::ErrorWrapper {
 527                    error: e.into(),
 528                    include_schema,
 529                }
 530                .into_response();
 531            }
 532        };
 533
 534        let Some(op_info) = ops_info
 535            .iter()
 536            .find(|o| o.id.eq_ignore_ascii_case(&service))
 537        else {
 538            return crate::sovd::error::ApiError::NotFound(Some(format!(
 539                "Operation '{service}' not found"
 540            )))
 541            .into_response();
 542        };
 543
 544        let schema = if include_schema {
 545            Some(crate::sovd::create_schema!(
 546                sovd_interfaces::components::ecu::operations::OperationCollectionItem
 547            ))
 548        } else {
 549            None
 550        };
 551
 552        (
 553            http::StatusCode::OK,
 554            axum::Json(sovd_interfaces::Items {
 555                items: vec![
 556                    sovd_interfaces::components::ecu::operations::OperationCollectionItem {
 557                        id: op_info.id.clone(),
 558                        name: op_info.name.clone(),
 559                        proximity_proof_required: false,
 560                        asynchronous_execution: op_info.has_stop || op_info.has_request_results,
 561                    },
 562                ],
 563                schema,
 564            }),
 565        )
 566            .into_response()
 567    }
 568
 569    async fn get_sdgs_handler<T: cda_interfaces::UdsEcu + Clone>(
 570        service: String,
 571        ecu_name: &str,
 572        gateway: &T,
 573        include_schema: bool,
 574    ) -> axum::response::Response {
 575        use axum::response::IntoResponse as _;
 576        use cda_interfaces::{DiagComm, DiagCommType, HashMap, HashMapExtensions};
 577
 578        use crate::sovd::IntoSovd;
 579
 580        let service_ops = vec![
 581            DiagComm {
 582                name: service.clone(),
 583                type_: DiagCommType::Operations,
 584                lookup_name: None,
 585                subfunction_id: None,
 586            },
 587            DiagComm {
 588                name: service.clone(),
 589                type_: DiagCommType::Operations,
 590                lookup_name: None,
 591                subfunction_id: None,
 592            },
 593            DiagComm {
 594                name: service,
 595                type_: DiagCommType::Operations,
 596                lookup_name: None,
 597                subfunction_id: None,
 598            },
 599        ];
 600        let schema = if include_schema {
 601            Some(crate::sovd::create_schema!(
 602                sovd_interfaces::components::ecu::ServicesSdgs
 603            ))
 604        } else {
 605            None
 606        };
 607        let mut resp = sovd_interfaces::components::ecu::ServicesSdgs {
 608            items: HashMap::new(),
 609            schema,
 610        };
 611        for service in service_ops {
 612            match gateway.get_sdgs(ecu_name, Some(&service)).await {
 613                Ok(sdgs) => {
 614                    if sdgs.is_empty() {
 615                        continue;
 616                    }
 617                    resp.items.insert(
 618                        format!("{}_{:?}", service.name, service.action()).to_lowercase(),
 619                        sovd_interfaces::components::ecu::ServiceSdgs {
 620                            sdgs: sdgs.into_sovd(),
 621                        },
 622                    );
 623                }
 624                Err(e) => {
 625                    return crate::sovd::error::ErrorWrapper {
 626                        error: e.into(),
 627                        include_schema,
 628                    }
 629                    .into_response();
 630                }
 631            }
 632        }
 633        (http::StatusCode::OK, axum::Json(resp)).into_response()
 634    }
 635
 636    pub(crate) fn docs_get(
 637        op: aide::transform::TransformOperation,
 638    ) -> aide::transform::TransformOperation {
 639        use aide::transform::TransformParameter;
 640        op.description("Get a specific operation or its SDG metadata.")
 641            .parameter("x-sovd2uds-includesdgs", |op: TransformParameter<bool>| {
 642                op.description("Set to true to include sdgs.")
 643            })
 644            .response_with::<200, axum::Json<
 645                sovd_interfaces::Items<
 646                    sovd_interfaces::components::ecu::operations::OperationCollectionItem,
 647                >,
 648            >, _>(|res| res.description("Operation details or SDG metadata."))
 649            .with(crate::openapi::error_not_found)
 650            .with(crate::openapi::error_internal_server)
 651    }
 652
 653    /// `GET /operations/{service}/docs` - online capability description
 654    pub(crate) mod docs_endpoint {
 655        use aide::{UseApi, openapi::OpenApi, transform::TransformOperation};
 656        use axum::{
 657            Json,
 658            extract::{Path, State},
 659            http::StatusCode,
 660            response::{IntoResponse as _, Response},
 661        };
 662        use cda_interfaces::{
 663            DiagComm, DiagCommType, DynamicPlugin, SchemaProvider, UdsEcu,
 664            file_manager::FileManager, subfunction_ids,
 665        };
 666        use cda_plugin_security::Secured;
 667
 668        use crate::{
 669            openapi,
 670            sovd::{
 671                WebserverEcuState,
 672                docs::{self, operations::OperationDocsMeta},
 673                error::ApiError,
 674            },
 675        };
 676
 677        openapi::aide_helper::gen_path_param!(OperationNamePathParam service String);
 678
 679        pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
 680            UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
 681            Path(OperationNamePathParam { service }): Path<OperationNamePathParam>,
 682            State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
 683        ) -> Response {
 684            let security_plugin: DynamicPlugin = security_plugin;
 685
 686            let ops_info = match uds
 687                .get_components_operations_info(&ecu_name, &security_plugin)
 688                .await
 689            {
 690                Ok(info) => info,
 691                Err(e) => return ApiError::from(e).into_response(),
 692            };
 693
 694            let Some(op_info) = ops_info
 695                .iter()
 696                .find(|o| o.id.eq_ignore_ascii_case(&service))
 697            else {
 698                return ApiError::NotFound(Some(format!("Operation '{service}' not found")))
 699                    .into_response();
 700            };
 701
 702            let is_async = op_info.has_stop || op_info.has_request_results;
 703
 704            let diag_service = DiagComm {
 705                name: service.clone(),
 706                type_: DiagCommType::Operations,
 707                lookup_name: None,
 708                subfunction_id: Some(subfunction_ids::routine::START),
 709            };
 710
 711            let request_params_schema = uds
 712                .schema_for_request(&ecu_name, &diag_service)
 713                .await
 714                .ok()
 715                .and_then(cda_interfaces::SchemaDescription::into_schema);
 716
 717            let response_params_schema = uds
 718                .schema_for_responses(&ecu_name, &diag_service)
 719                .await
 720                .ok()
 721                .and_then(cda_interfaces::SchemaDescription::into_schema);
 722
 723            let meta = OperationDocsMeta {
 724                name: service.clone(),
 725                is_async,
 726                request_params_schema,
 727                response_params_schema,
 728            };
 729
 730            let base_path = format!("/components/{ecu_name}/operations/{service}");
 731            let path_items = docs::operations::build_path_items(&base_path, &meta);
 732            let doc = docs::build_openapi_doc(&format!("Operation: {service}"), path_items);
 733
 734            (StatusCode::OK, Json(doc)).into_response()
 735        }
 736
 737        pub(crate) fn docs_transform(op: TransformOperation) -> TransformOperation {
 738            op.description(
 739                "Online capability description for a specific operation on this ECU component \
 740                 (ISO 17978-3 Section 7.5). Returns a self-contained OpenAPI specification.",
 741            )
 742            .response_with::<200, Json<OpenApi>, _>(|res| {
 743                res.description("Self-contained OpenAPI 3.1 specification for this operation.")
 744            })
 745            .with(openapi::error_not_found)
 746        }
 747    }
 748
 749    pub(crate) mod executions {
 750        use aide::{UseApi, transform::TransformOperation};
 751        use axum::{
 752            Json,
 753            body::Bytes,
 754            extract::{OriginalUri, Path, Query, State},
 755            http::{HeaderMap, StatusCode, header},
 756            response::{IntoResponse as _, Response},
 757        };
 758        use axum_extra::extract::{Host, WithRejection};
 759        use cda_interfaces::{
 760            DiagComm, DiagCommType, DynamicPlugin, SchemaProvider, UdsEcu,
 761            diagservices::{DiagServiceJsonResponse, DiagServiceResponse, DiagServiceResponseType},
 762            file_manager::FileManager,
 763            subfunction_ids,
 764        };
 765        use cda_plugin_security::{Secured, SecurityPlugin};
 766        use sovd_interfaces::{
 767            common::operations::OperationIdItem,
 768            components::ecu::operations::{
 769                AsyncGetByIdResponse, AsyncPostResponse, ExecutionStatus, OperationDeleteQuery,
 770                OperationQuery, service::executions as sovd_executions,
 771            },
 772        };
 773        use uuid::Uuid;
 774
 775        use crate::{
 776            openapi,
 777            sovd::{
 778                self, ServiceExecution, WebserverEcuState, api_error_from_diag_response,
 779                components::get_content_type_and_accept,
 780                create_response_schema, create_schema,
 781                error::{ApiError, ErrorWrapper, VendorErrorCode},
 782                field_parse_errors_to_json, finalize_execution, guard_execution,
 783                locks::validate_lock,
 784                remove_reserved_execution, reserve_execution,
 785            },
 786        };
 787
 788        openapi::aide_helper::gen_path_param!(OperationServicePathParam service String);
 789
 790        /// Options forwarded from the HTTP layer into `ecu_operation_write_handler`.
 791        pub(crate) struct WriteHandlerOptions {
 792            pub include_schema: bool,
 793            pub suppress_service: bool,
 794            pub base_path: String,
 795        }
 796
 797        /// Request data forwarded from the HTTP layer into `ecu_operation_write_handler`.
 798        pub(crate) struct WriteHandlerRequest {
 799            pub service: String,
 800            pub headers: HeaderMap,
 801            pub body: Bytes,
 802        }
 803
 804        pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
 805            UseApi(Secured(_security_plugin), _): UseApi<Secured, ()>,
 806            Path(OperationServicePathParam { service }): Path<OperationServicePathParam>,
 807            WithRejection(Query(query), _): WithRejection<Query<sovd_executions::Query>, ApiError>,
 808            State(WebserverEcuState {
 809                service_executions, ..
 810            }): State<WebserverEcuState<T, U>>,
 811        ) -> Response {
 812            let schema = if query.include_schema {
 813                Some(create_schema!(sovd_interfaces::Items<OperationIdItem>))
 814            } else {
 815                None
 816            };
 817            let ids: Vec<OperationIdItem> = service_executions
 818                .read()
 819                .await
 820                .get(&service)
 821                .map(|op_map| {
 822                    op_map
 823                        .iter()
 824                        .filter(|(_, v)| v.is_created)
 825                        .map(|(k, _)| OperationIdItem { id: k.to_string() })
 826                        .collect()
 827                })
 828                .unwrap_or_default();
 829            (
 830                StatusCode::OK,
 831                Json(sovd_interfaces::Items { items: ids, schema }),
 832            )
 833                .into_response()
 834        }
 835
 836        pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
 837            op.description("List all active service operation executions")
 838                .response_with::<200, Json<sovd_interfaces::Items<String>>, _>(|res| {
 839                    res.description("List of active execution ids.")
 840                })
 841        }
 842
 843        #[allow(
 844            clippy::too_many_arguments,
 845            reason = "Axum extractors cannot be combined without a new custom extractor"
 846        )]
 847        pub(crate) async fn post<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
 848            UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
 849            Path(OperationServicePathParam { service }): Path<OperationServicePathParam>,
 850            WithRejection(Query(query), _): WithRejection<Query<OperationQuery>, ApiError>,
 851            State(WebserverEcuState {
 852                ecu_name,
 853                uds,
 854                locks,
 855                service_executions,
 856                update_in_progress,
 857                ..
 858            }): State<WebserverEcuState<T, U>>,
 859            UseApi(Host(host), _): UseApi<Host, String>,
 860            OriginalUri(uri): OriginalUri,
 861            headers: HeaderMap,
 862            body: Bytes,
 863        ) -> Response {
 864            let claims = security_plugin.as_auth_plugin().claims();
 865            if let Some(response) =
 866                validate_lock(&claims, &ecu_name, &locks, query.include_schema).await
 867            {
 868                return response;
 869            }
 870            ecu_operation_write_handler::<T>(
 871                WriteHandlerRequest {
 872                    service,
 873                    headers,
 874                    body,
 875                },
 876                &ecu_name,
 877                &uds,
 878                service_executions,
 879                security_plugin,
 880                WriteHandlerOptions {
 881                    include_schema: query.include_schema,
 882                    suppress_service: query.suppress_service,
 883                    base_path: format!("http://{host}{uri}"),
 884                },
 885                &update_in_progress,
 886            )
 887            .await
 888        }
 889
 890        pub(crate) fn docs_post(op: TransformOperation) -> TransformOperation {
 891            openapi::request_json_and_octet::<sovd_executions::Request>(op)
 892                .description("Start a new operation execution (Start subfunction)")
 893                .response_with::<200, Json<sovd_executions::Response<VendorErrorCode>>, _>(|res| {
 894                    let mut res = res
 895                        .description("Execution started, synchronous result.")
 896                        .example(sovd_executions::Response {
 897                            parameters: Some(serde_json::Map::from_iter([(
 898                                "example_param".to_string(),
 899                                serde_json::Value::String("example_value".to_string()),
 900                            )])),
 901                            error: None,
 902                            schema: None,
 903                        });
 904                    res.inner().content.insert(
 905                        "application/octet-stream".to_owned(),
 906                        aide::openapi::MediaType {
 907                            example: Some(serde_json::json!([0xABu8, 0xCD, 0xEF, 0x00])),
 908                            ..Default::default()
 909                        },
 910                    );
 911                    res
 912                })
 913                .response_with::<202, Json<AsyncPostResponse>, _>(|res| {
 914                    res.description(
 915                        "Execution started asynchronously. Use the returned id for GET/DELETE on \
 916                         /executions/{id}.",
 917                    )
 918                })
 919                .with(openapi::error_bad_request)
 920                .with(openapi::error_not_found)
 921                .with(openapi::error_forbidden)
 922                .with(openapi::error_conflict)
 923                .with(openapi::error_internal_server)
 924                .with(openapi::error_bad_gateway)
 925        }
 926
 927        fn validate_and_parse_write_request(
 928            headers: &HeaderMap,
 929            body: &Bytes,
 930        ) -> Result<(Option<cda_interfaces::diagservices::UdsPayloadData>, bool), ApiError>
 931        {
 932            let (Some(content_type), accept) = get_content_type_and_accept(headers)? else {
 933                return Err(ApiError::BadRequest("Missing Content-Type".to_owned()));
 934            };
 935            let data = sovd::get_payload_data::<sovd_executions::Request>(
 936                Some(&content_type),
 937                headers,
 938                body,
 939            )?;
 940            if accept != mime::APPLICATION_OCTET_STREAM && accept != mime::APPLICATION_JSON {
 941                return Err(ApiError::BadRequest(format!(
 942                    "Unsupported Accept header: {accept:?}"
 943                )));
 944            }
 945            Ok((data, accept == mime::APPLICATION_JSON))
 946        }
 947
 948        pub(crate) async fn ecu_operation_write_handler<T: UdsEcu + SchemaProvider + Clone>(
 949            req: WriteHandlerRequest,
 950            ecu_name: &str,
 951            uds: &T,
 952            service_executions: std::sync::Arc<
 953                tokio::sync::RwLock<
 954                    cda_interfaces::HashMap<String, indexmap::IndexMap<Uuid, ServiceExecution>>,
 955                >,
 956            >,
 957            security_plugin: Box<dyn SecurityPlugin>,
 958            opts: WriteHandlerOptions,
 959            update_in_progress: &std::sync::atomic::AtomicBool,
 960        ) -> Response {
 961            let WriteHandlerRequest {
 962                service,
 963                headers,
 964                body,
 965            } = req;
 966            let WriteHandlerOptions {
 967                include_schema,
 968                suppress_service,
 969                base_path,
 970            } = opts;
 971            let err_response = |error: ApiError| -> Response {
 972                ErrorWrapper {
 973                    error,
 974                    include_schema,
 975                }
 976                .into_response()
 977            };
 978            if service == "reset" {
 979                return ecu_reset_handler::<T>(
 980                    service,
 981                    ecu_name,
 982                    uds,
 983                    body,
 984                    security_plugin,
 985                    include_schema,
 986                )
 987                .await;
 988            }
 989
 990            // Reserve an execution slot atomically: checks for a running
 991            // conflict and, if none, inserts a placeholder so that a second
 992            // concurrent POST for the same operation sees 409 Conflict.
 993            let exec_id = match reserve_execution(
 994                &service_executions,
 995                &service,
 996                &service,
 997                include_schema,
 998                update_in_progress,
 999            )
1000            .await
1001            {
1002                Ok(id) => id,
1003                Err(err) => return err.into_response(),
1004            };
1005
1006            let security_plugin: DynamicPlugin = security_plugin;
1007            let is_async = if suppress_service {
1008                true
1009            } else {
1010                match check_if_async(uds, ecu_name, &service, &security_plugin).await {
1011                    Ok(v) => v,
1012                    Err(e) => {
1013                        remove_reserved_execution(&service_executions, &service, &exec_id).await;
1014                        return err_response(e);
1015                    }
1016                }
1017            };
1018
1019            let (data, map_to_json) = match validate_and_parse_write_request(&headers, &body) {
1020                Ok(v) => v,
1021                Err(e) => {
1022                    remove_reserved_execution(&service_executions, &service, &exec_id).await;
1023                    return err_response(e);
1024                }
1025            };
1026
1027            let diag_service = DiagComm {
1028                name: service.clone(),
1029                type_: DiagCommType::Operations,
1030                lookup_name: None,
1031                subfunction_id: Some(subfunction_ids::routine::START),
1032            };
1033            let response = if suppress_service {
1034                None
1035            } else {
1036                match send_start_request(
1037                    uds,
1038                    ecu_name,
1039                    diag_service.clone(),
1040                    &security_plugin,
1041                    data,
1042                    map_to_json,
1043                    include_schema,
1044                )
1045                .await
1046                {
1047                    Ok(r) => r,
1048                    Err(e) => {
1049                        remove_reserved_execution(&service_executions, &service, &exec_id).await;
1050                        return e;
1051                    }
1052                }
1053            };
1054
1055            if is_async {
1056                handle_async_post::<T>(
1057                    response,
1058                    map_to_json,
1059                    include_schema,
1060                    base_path,
1061                    service,
1062                    service_executions,
1063                    exec_id,
1064                )
1065                .await
1066            } else {
1067                remove_reserved_execution(&service_executions, &service, &exec_id).await;
1068                handle_sync_post::<T>(
1069                    response,
1070                    map_to_json,
1071                    include_schema,
1072                    ecu_name,
1073                    uds,
1074                    &diag_service,
1075                )
1076                .await
1077            }
1078        }
1079
1080        /// Returns whether the operation is async (has Stop or `RequestResults`
1081        /// subfunctions).
1082        async fn check_if_async<T: UdsEcu>(
1083            uds: &T,
1084            ecu_name: &str,
1085            service: &str,
1086            security_plugin: &DynamicPlugin,
1087        ) -> Result<bool, ApiError> {
1088            let sf = uds
1089                .get_routine_subfunctions(ecu_name, service, security_plugin)
1090                .await
1091                .map_err(ApiError::from)?;
1092            Ok(sf.has_stop || sf.has_request_results)
1093        }
1094
1095        /// Sends the Start subfunction request and returns the positive response, or
1096        /// `Err(Response)` if the UDS call failed or returned a negative response.
1097        async fn send_start_request<T: UdsEcu>(
1098            uds: &T,
1099            ecu_name: &str,
1100            diag_service: DiagComm,
1101            security_plugin: &DynamicPlugin,
1102            data: Option<cda_interfaces::diagservices::UdsPayloadData>,
1103            map_to_json: bool,
1104            include_schema: bool,
1105        ) -> Result<Option<T::Response>, Response> {
1106            let response = match uds
1107                .send(ecu_name, diag_service, security_plugin, data, map_to_json)
1108                .await
1109            {
1110                Ok(v) => v,
1111                Err(e) => {
1112                    return Err(ErrorWrapper {
1113                        error: e.into(),
1114                        include_schema,
1115                    }
1116                    .into_response());
1117                }
1118            };
1119            if let DiagServiceResponseType::Negative = response.response_type() {
1120                return Err(api_error_from_diag_response(&response, include_schema).into_response());
1121            }
1122            Ok(Some(response))
1123        }
1124
1125        fn err_invalid_content(
1126            detail: String,
1127        ) -> sovd_interfaces::error::DataError<VendorErrorCode> {
1128            sovd_interfaces::error::DataError {
1129                path: String::new(),
1130                error: sovd_interfaces::error::ApiErrorResponse {
1131                    message: detail,
1132                    error_code: sovd_interfaces::error::ErrorCode::InvalidResponseContent,
1133                    vendor_code: Some(VendorErrorCode::ErrorInterpretingMessage),
1134                    parameters: None,
1135                    error_source: None,
1136                    schema: None,
1137                },
1138            }
1139        }
1140
1141        /// Parses a positive, non-empty UDS response into `(parameters, errors)`.
1142        ///
1143        /// - `Object` -> parameters map + any field-parse errors
1144        /// - `Null`  -> empty parameters, no errors (ECU signalled no output)
1145        /// - anything else, or a parse failure -> empty parameters, one soft `DataError`
1146        fn parse_json_response_params<R: DiagServiceResponse>(
1147            response: R,
1148            context: &str,
1149        ) -> (
1150            serde_json::Map<String, serde_json::Value>,
1151            Vec<sovd_interfaces::error::DataError<VendorErrorCode>>,
1152        ) {
1153            match response.into_json() {
1154                Ok(DiagServiceJsonResponse {
1155                    data: serde_json::Value::Object(m),
1156                    errors,
1157                }) => (m, field_parse_errors_to_json(errors, "parameters")),
1158                Ok(DiagServiceJsonResponse {
1159                    data: serde_json::Value::Null,
1160                    ..
1161                }) => (serde_json::Map::new(), vec![]),
1162                Ok(v) => (
1163                    serde_json::Map::new(),
1164                    vec![err_invalid_content(format!(
1165                        "Expected JSON object but got: {}",
1166                        v.data
1167                    ))],
1168                ),
1169                Err(e) => (
1170                    serde_json::Map::new(),
1171                    vec![err_invalid_content(format!(
1172                        "Failed to parse {context} response: {e:?}"
1173                    ))],
1174                ),
1175            }
1176        }
1177
1178        fn parse_exec_uuid(id: &str, include_schema: bool) -> Result<Uuid, ErrorWrapper> {
1179            Uuid::parse_str(id).map_err(|e| ErrorWrapper {
1180                error: ApiError::BadRequest(format!("{e:?}")),
1181                include_schema,
1182            })
1183        }
1184
1185        /// Builds the `200 OK` `AsyncGetByIdResponse` body used by both the
1186        /// `RequestResults` success path and the `suppress_service` fallback path.
1187        /// An empty `parameters` map is serialised as `null` per the SOVD spec.
1188        fn get_by_id_response(
1189            status: ExecutionStatus,
1190            parameters: serde_json::Map<String, serde_json::Value>,
1191            error: Vec<sovd_interfaces::error::DataError<VendorErrorCode>>,
1192            include_schema: bool,
1193        ) -> Response {
1194            use sovd_interfaces::components::ecu::operations::GetByIdCapability;
1195            let schema = if include_schema {
1196                Some(create_schema!(AsyncGetByIdResponse<VendorErrorCode>))
1197            } else {
1198                None
1199            };
1200            let parameters = if parameters.is_empty() {
1201                None
1202            } else {
1203                Some(parameters)
1204            };
1205            (
1206                StatusCode::OK,
1207                Json(AsyncGetByIdResponse::<VendorErrorCode> {
1208                    status,
1209                    capability: GetByIdCapability::Execute,
1210                    parameters,
1211                    progress: None,
1212                    error,
1213                    schema,
1214                }),
1215            )
1216                .into_response()
1217        }
1218
1219        /// Handles the async (Stop/RequestResults) POST path: finalises the
1220        /// previously reserved execution with the Start-response parameters,
1221        /// then returns 202 Accepted with only `id` and `status` per spec Table 184.
1222        async fn handle_async_post<T: UdsEcu>(
1223            response: Option<T::Response>,
1224            map_to_json: bool,
1225            include_schema: bool,
1226            base_path: String,
1227            service: String,
1228            service_executions: std::sync::Arc<
1229                tokio::sync::RwLock<
1230                    cda_interfaces::HashMap<String, indexmap::IndexMap<Uuid, ServiceExecution>>,
1231                >,
1232            >,
1233            exec_id: Uuid,
1234        ) -> Response {
1235            let parameters = match response {
1236                Some(r) if map_to_json && !r.is_empty() => match r.into_json() {
1237                    Ok(DiagServiceJsonResponse {
1238                        data: serde_json::Value::Object(m),
1239                        ..
1240                    }) => m,
1241                    _ => serde_json::Map::new(),
1242                },
1243                _ => serde_json::Map::new(),
1244            };
1245            finalize_execution(&service_executions, &service, &exec_id, |exec| {
1246                exec.parameters = parameters;
1247            })
1248            .await;
1249            let schema = if include_schema {
1250                Some(create_schema!(AsyncPostResponse))
1251            } else {
1252                None
1253            };
1254            (
1255                StatusCode::ACCEPTED,
1256                [(header::LOCATION, format!("{base_path}/{exec_id}"))],
1257                Json(AsyncPostResponse {
1258                    id: exec_id.to_string(),
1259                    status: Some(ExecutionStatus::Running),
1260                    schema,
1261                }),
1262            )
1263                .into_response()
1264        }
1265
1266        /// Handles the synchronous (no Stop/RequestResults) POST path: returns 200 OK
1267        /// with the mapped response parameters, or raw bytes if the accept header
1268        /// requested octet-stream.
1269        async fn handle_sync_post<T: UdsEcu + SchemaProvider>(
1270            response: Option<T::Response>,
1271            map_to_json: bool,
1272            include_schema: bool,
1273            ecu_name: &str,
1274            uds: &T,
1275            diag_service: &DiagComm,
1276        ) -> Response {
1277            let schema = if map_to_json && include_schema {
1278                let subschema = get_subschema(ecu_name, uds, diag_service).await;
1279                Some(create_response_schema!(
1280                    sovd_executions::Response<VendorErrorCode>,
1281                    "parameters",
1282                    subschema
1283                ))
1284            } else {
1285                None
1286            };
1287
1288            if map_to_json {
1289                let (mapped_data, parse_errors) =
1290                    match response.and_then(|r| (!r.is_empty()).then(|| r.into_json())) {
1291                        None => (serde_json::Map::new(), vec![]),
1292                        Some(Ok(DiagServiceJsonResponse {
1293                            data: serde_json::Value::Object(mapped_data),
1294                            errors,
1295                        })) => (mapped_data, errors),
1296                        Some(Ok(v)) => {
1297                            return ErrorWrapper {
1298                                error: ApiError::InternalServerError(Some(format!(
1299                                    "Expected JSON object but got: {}",
1300                                    v.data
1301                                ))),
1302                                include_schema,
1303                            }
1304                            .into_response();
1305                        }
1306                        Some(Err(e)) => {
1307                            return ErrorWrapper {
1308                                error: ApiError::InternalServerError(Some(format!("{e:?}"))),
1309                                include_schema,
1310                            }
1311                            .into_response();
1312                        }
1313                    };
1314                // Spec Table 183: `error` is singular (first parse error wins).
1315                let error = field_parse_errors_to_json(parse_errors, "parameters")
1316                    .into_iter()
1317                    .next();
1318                let parameters = if mapped_data.is_empty() {
1319                    None
1320                } else {
1321                    Some(mapped_data)
1322                };
1323                (
1324                    StatusCode::OK,
1325                    Json(sovd_executions::Response {
1326                        parameters,
1327                        error,
1328                        schema,
1329                    }),
1330                )
1331                    .into_response()
1332            } else {
1333                let data = response.map_or(vec![], |r| r.get_raw().to_vec());
1334                (StatusCode::OK, Bytes::from_owner(data)).into_response()
1335            }
1336        }
1337
1338        #[allow(
1339            clippy::too_many_lines,
1340            reason = "Current implementation has little potential to extract smaller functions"
1341        )]
1342        async fn ecu_reset_handler<T: UdsEcu + SchemaProvider + Clone>(
1343            service: String,
1344            ecu_name: &str,
1345            uds: &T,
1346            body: Bytes,
1347            security_plugin: Box<dyn SecurityPlugin>,
1348            include_schema: bool,
1349        ) -> Response {
1350            // todo: in the future we have to handle possible parameters for the reset service
1351            let Some(request_parameters) =
1352                serde_json::from_slice::<sovd_executions::Request>(&body)
1353                    .ok()
1354                    .and_then(|v| v.parameters)
1355            else {
1356                return ErrorWrapper {
1357                    error: ApiError::BadRequest("Invalid request body".to_string()),
1358                    include_schema,
1359                }
1360                .into_response();
1361            };
1362
1363            let Some(value) = request_parameters.get("value") else {
1364                return ErrorWrapper {
1365                    error: ApiError::BadRequest(
1366                        "Missing 'value' parameter in request body".to_owned(),
1367                    ),
1368                    include_schema,
1369                }
1370                .into_response();
1371            };
1372
1373            let Some(value_str) = value.as_str() else {
1374                return ErrorWrapper {
1375                    error: ApiError::BadRequest(
1376                        "The 'value' parameter must be a string".to_owned(),
1377                    ),
1378                    include_schema,
1379                }
1380                .into_response();
1381            };
1382
1383            let allowed_values = match uds.get_ecu_reset_services(ecu_name).await {
1384                Ok(v) => v,
1385                Err(e) => {
1386                    return ErrorWrapper {
1387                        error: e.into(),
1388                        include_schema,
1389                    }
1390                    .into_response();
1391                }
1392            };
1393
1394            if !allowed_values
1395                .iter()
1396                .any(|v| v.eq_ignore_ascii_case(value_str))
1397            {
1398                return ErrorWrapper {
1399                    error: ApiError::BadRequest(format!(
1400                        "Invalid value for reset service: {value_str}. Allowed values: [{}]",
1401                        allowed_values.join(", ")
1402                    )),
1403                    include_schema,
1404                }
1405                .into_response();
1406            }
1407
1408            let diag_service = DiagComm {
1409                name: service.clone(),
1410                type_: DiagCommType::Modes, // ecureset is in modes
1411                lookup_name: Some(value_str.to_owned()),
1412                subfunction_id: None,
1413            };
1414
1415            let schema = if include_schema {
1416                let subschema = get_subschema(ecu_name, uds, &diag_service).await;
1417                Some(create_response_schema!(
1418                    sovd_executions::Response<VendorErrorCode>,
1419                    "parameters",
1420                    subschema
1421                ))
1422            } else {
1423                None
1424            };
1425
1426            let response = match uds
1427                .send(
1428                    ecu_name,
1429                    diag_service,
1430                    &(security_plugin as DynamicPlugin),
1431                    None,
1432                    true,
1433                )
1434                .await
1435            {
1436                Ok(v) => v,
1437                Err(e) => {
1438                    return ErrorWrapper {
1439                        error: e.into(),
1440                        include_schema,
1441                    }
1442                    .into_response();
1443                }
1444            };
1445
1446            match response.response_type() {
1447                DiagServiceResponseType::Negative => {
1448                    api_error_from_diag_response(&response, include_schema).into_response()
1449                }
1450                DiagServiceResponseType::Positive => {
1451                    if response.is_empty() {
1452                        StatusCode::NO_CONTENT.into_response()
1453                    } else {
1454                        let (response_data, parse_errors) = match response.into_json() {
1455                            Ok(DiagServiceJsonResponse {
1456                                data: serde_json::Value::Object(mapped_data),
1457                                errors,
1458                            }) => (mapped_data, errors),
1459                            Ok(DiagServiceJsonResponse {
1460                                data: serde_json::Value::Null,
1461                                errors,
1462                            }) => {
1463                                if errors.is_empty() {
1464                                    return StatusCode::NO_CONTENT.into_response();
1465                                }
1466                                (serde_json::Map::new(), errors)
1467                            }
1468                            Ok(v) => {
1469                                return ErrorWrapper {
1470                                    error: ApiError::InternalServerError(Some(format!(
1471                                        "Expected JSON object but got: {}",
1472                                        v.data
1473                                    ))),
1474                                    include_schema,
1475                                }
1476                                .into_response();
1477                            }
1478                            Err(e) => {
1479                                return ErrorWrapper {
1480                                    error: ApiError::InternalServerError(Some(format!("{e:?}"))),
1481                                    include_schema,
1482                                }
1483                                .into_response();
1484                            }
1485                        };
1486                        let error = field_parse_errors_to_json(parse_errors, "parameters")
1487                            .into_iter()
1488                            .next();
1489                        let parameters = if response_data.is_empty() {
1490                            None
1491                        } else {
1492                            Some(response_data)
1493                        };
1494                        (
1495                            StatusCode::OK,
1496                            Json(sovd_executions::Response {
1497                                parameters,
1498                                error,
1499                                schema,
1500                            }),
1501                        )
1502                            .into_response()
1503                    }
1504                }
1505            }
1506        }
1507
1508        async fn get_subschema<T: SchemaProvider>(
1509            ecu_name: &str,
1510            uds: &T,
1511            diag_service: &DiagComm,
1512        ) -> Option<schemars::Schema> {
1513            match uds
1514                .schema_for_responses(ecu_name, diag_service)
1515                .await
1516                .map(cda_interfaces::SchemaDescription::into_schema)
1517            {
1518                Ok(v) => v,
1519                Err(e) => {
1520                    tracing::error!(
1521                        error = ?e,
1522                        diag_service = ?diag_service,
1523                        "Failed to get schema for diag service"
1524                    );
1525                    None
1526                }
1527            }
1528        }
1529
1530        pub(crate) mod id {
1531            use super::*;
1532
1533            #[derive(serde::Deserialize, schemars::JsonSchema)]
1534            pub(crate) struct ServiceAndIdPathParam {
1535                pub service: String,
1536                pub id: String,
1537            }
1538
1539            /// Process a successful UDS `RequestResults` response: parse it, update stored
1540            /// execution state, and return the `200 OK` response body.
1541            async fn get_operations_response<T: UdsEcu>(
1542                response: T::Response,
1543                service: &str,
1544                exec_id: Uuid,
1545                service_executions: &tokio::sync::RwLock<
1546                    cda_interfaces::HashMap<String, indexmap::IndexMap<Uuid, ServiceExecution>>,
1547                >,
1548                include_schema: bool,
1549            ) -> Response {
1550                if let DiagServiceResponseType::Negative = response.response_type() {
1551                    tracing::warn!(
1552                        exec_id = %exec_id,
1553                        "RequestResults subfunction returned negative response"
1554                    );
1555                    return api_error_from_diag_response(&response, include_schema).into_response();
1556                }
1557
1558                let (parameters, error_list) = if response.is_empty() {
1559                    (serde_json::Map::new(), vec![])
1560                } else {
1561                    parse_json_response_params::<T::Response>(response, "RequestResults")
1562                };
1563
1564                if let Some(stored_mut) = service_executions
1565                    .write()
1566                    .await
1567                    .get_mut(service)
1568                    .and_then(|m| m.get_mut(&exec_id))
1569                {
1570                    stored_mut.parameters.clone_from(&parameters);
1571                }
1572
1573                get_by_id_response(
1574                    ExecutionStatus::Completed,
1575                    parameters,
1576                    error_list,
1577                    include_schema,
1578                )
1579            }
1580
1581            pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
1582                UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
1583                Path(ServiceAndIdPathParam { service, id }): Path<ServiceAndIdPathParam>,
1584                WithRejection(Query(query), _): WithRejection<Query<OperationQuery>, ApiError>,
1585                State(WebserverEcuState {
1586                    ecu_name,
1587                    uds,
1588                    service_executions,
1589                    ..
1590                }): State<WebserverEcuState<T, U>>,
1591            ) -> Response {
1592                let include_schema = query.include_schema;
1593                let exec_id = match parse_exec_uuid(&id, include_schema) {
1594                    Ok(v) => v,
1595                    Err(e) => return e.into_response(),
1596                };
1597
1598                let stored = match guard_execution(
1599                    &service_executions,
1600                    &service,
1601                    exec_id,
1602                    include_schema,
1603                    &format!("Execution {exec_id} is already in progress"),
1604                )
1605                .await
1606                {
1607                    Ok(v) => v,
1608                    Err(e) => return e.into_response(),
1609                };
1610
1611                // suppress_service: skip the UDS send, return stored state directly
1612                if query.suppress_service {
1613                    if let Some(exec) = service_executions
1614                        .write()
1615                        .await
1616                        .get_mut(&service)
1617                        .and_then(|m| m.get_mut(&exec_id))
1618                    {
1619                        exec.in_flight = false;
1620                    }
1621                    return get_by_id_response(
1622                        stored.status,
1623                        stored.parameters,
1624                        vec![],
1625                        include_schema,
1626                    );
1627                }
1628
1629                // Try to send RequestResults (subfunction 0x03)
1630                let diag_service = DiagComm {
1631                    name: service.clone(),
1632                    type_: DiagCommType::Operations,
1633                    lookup_name: None,
1634                    subfunction_id: Some(subfunction_ids::routine::REQUEST_RESULTS),
1635                };
1636                let uds_result = uds
1637                    .send(
1638                        &ecu_name,
1639                        diag_service,
1640                        &(security_plugin as DynamicPlugin),
1641                        None,
1642                        true,
1643                    )
1644                    .await;
1645
1646                if let Some(exec) = service_executions
1647                    .write()
1648                    .await
1649                    .get_mut(&service)
1650                    .and_then(|m| m.get_mut(&exec_id))
1651                {
1652                    exec.in_flight = false;
1653                }
1654
1655                match uds_result {
1656                    Err(e) => {
1657                        tracing::warn!(
1658                            error = ?e,
1659                            service = %service,
1660                            exec_id = %exec_id,
1661                            "RequestResults subfunction failed"
1662                        );
1663                        ErrorWrapper {
1664                            error: e.into(),
1665                            include_schema,
1666                        }
1667                        .into_response()
1668                    }
1669                    Ok(response) => {
1670                        get_operations_response::<T>(
1671                            response,
1672                            &service,
1673                            exec_id,
1674                            &service_executions,
1675                            include_schema,
1676                        )
1677                        .await
1678                    }
1679                }
1680            }
1681
1682            pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
1683                op.description(
1684                    "Get the result of an async operation execution (RequestResults subfunction)",
1685                )
1686                .response_with::<200, Json<AsyncGetByIdResponse<VendorErrorCode>>, _>(|res| {
1687                    res.description("Execution result retrieved successfully.")
1688                })
1689                .with(openapi::error_not_found)
1690                .with(openapi::error_bad_request)
1691                .with(openapi::error_bad_gateway)
1692            }
1693
1694            pub(crate) async fn delete<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
1695                UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
1696                Path(ServiceAndIdPathParam { service, id }): Path<ServiceAndIdPathParam>,
1697                WithRejection(Query(query), _): WithRejection<
1698                    Query<OperationDeleteQuery>,
1699                    ApiError,
1700                >,
1701                State(WebserverEcuState {
1702                    ecu_name,
1703                    uds,
1704                    locks,
1705                    service_executions,
1706                    ..
1707                }): State<WebserverEcuState<T, U>>,
1708            ) -> Response {
1709                let include_schema = query.include_schema;
1710                let claims = security_plugin.as_auth_plugin().claims();
1711                if let Some(response) =
1712                    validate_lock(&claims, &ecu_name, &locks, include_schema).await
1713                {
1714                    return response;
1715                }
1716                let exec_id = match parse_exec_uuid(&id, include_schema) {
1717                    Ok(v) => v,
1718                    Err(e) => return e.into_response(),
1719                };
1720
1721                if let Err(e) = guard_execution(
1722                    &service_executions,
1723                    &service,
1724                    exec_id,
1725                    include_schema,
1726                    &format!("Execution {exec_id} is already being stopped"),
1727                )
1728                .await
1729                {
1730                    return e.into_response();
1731                }
1732
1733                let diag_service = DiagComm {
1734                    name: service.clone(),
1735                    type_: DiagCommType::Operations,
1736                    lookup_name: None,
1737                    subfunction_id: Some(subfunction_ids::routine::STOP),
1738                };
1739                let uds_result = uds
1740                    .send(
1741                        &ecu_name,
1742                        diag_service,
1743                        &(security_plugin as DynamicPlugin),
1744                        None,
1745                        true,
1746                    )
1747                    .await;
1748
1749                match uds_result {
1750                    Ok(r) if matches!(r.response_type(), DiagServiceResponseType::Positive) => {
1751                        if let Some(op_map) = service_executions.write().await.get_mut(&service) {
1752                            op_map.shift_remove(&exec_id);
1753                        }
1754                        if r.is_empty() {
1755                            StatusCode::NO_CONTENT.into_response()
1756                        } else {
1757                            let (parameters, error_list) =
1758                                parse_json_response_params::<T::Response>(r, "Stop");
1759                            get_by_id_response(
1760                                ExecutionStatus::Stopped,
1761                                parameters,
1762                                error_list,
1763                                include_schema,
1764                            )
1765                        }
1766                    }
1767                    Err(cda_interfaces::DiagServiceError::NotFound(_))
1768                        if query.suppress_service =>
1769                    {
1770                        tracing::warn!(
1771                            service = %service,
1772                            exec_id = %exec_id,
1773                            "Stop service not found (suppress_service=true), removing execution"
1774                        );
1775                        if let Some(op_map) = service_executions.write().await.get_mut(&service) {
1776                            op_map.shift_remove(&exec_id);
1777                        }
1778                        StatusCode::NO_CONTENT.into_response()
1779                    }
1780                    result => {
1781                        if let Err(e) = &result {
1782                            tracing::warn!(
1783                                error = ?e,
1784                                service = %service,
1785                                exec_id = %exec_id,
1786                                "Stop subfunction failed"
1787                            );
1788                        } else {
1789                            tracing::warn!(
1790                                service = %service,
1791                                exec_id = %exec_id,
1792                                "Stop subfunction returned negative response"
1793                            );
1794                        }
1795                        if query.force {
1796                            if let Some(op_map) = service_executions.write().await.get_mut(&service)
1797                            {
1798                                op_map.shift_remove(&exec_id);
1799                            }
1800                            return StatusCode::NO_CONTENT.into_response();
1801                        } else if let Some(exec) = service_executions
1802                            .write()
1803                            .await
1804                            .get_mut(&service)
1805                            .and_then(|m| m.get_mut(&exec_id))
1806                        {
1807                            // reset in_flight flag of the execution
1808                            exec.in_flight = false;
1809                        }
1810
1811                        match result {
1812                            Err(e) => ErrorWrapper {
1813                                error: e.into(),
1814                                include_schema,
1815                            }
1816                            .into_response(),
1817                            Ok(r) => {
1818                                api_error_from_diag_response(&r, include_schema).into_response()
1819                            }
1820                        }
1821                    }
1822                }
1823            }
1824
1825            pub(crate) fn docs_delete(op: TransformOperation) -> TransformOperation {
1826                op.description(
1827                    "Stop an async operation execution (Stop subfunction). Use \
1828                     ?x-sovd2uds-force=true to remove even on ECU error.",
1829                )
1830                .response_with::<204, (), _>(|res| {
1831                    res.description("Execution stopped and removed (no response data from ECU).")
1832                })
1833                .response_with::<200, Json<AsyncGetByIdResponse<VendorErrorCode>>, _>(|res| {
1834                    res.description(
1835                        "Execution stopped and removed. The ECU returned response data (non-spec \
1836                         extension).",
1837                    )
1838                })
1839                .with(openapi::error_not_found)
1840                .with(openapi::error_bad_request)
1841                .with(openapi::error_bad_gateway)
1842                .with(openapi::error_forbidden)
1843            }
1844        }
1845    }
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850    use sovd_interfaces::components::ecu::operations::comparams::ComParamSimpleValue;
1851
1852    #[test]
1853    #[allow(clippy::float_cmp, reason = "Test verifies exact deserialized values")]
1854    fn com_param_simple_deserialization() {
1855        let json_data_string = "\"example_value\"";
1856        let deserialized_string: ComParamSimpleValue =
1857            serde_json::from_str(json_data_string).unwrap();
1858        assert_eq!(deserialized_string.value, "example_value");
1859        assert!(deserialized_string.unit.is_none());
1860
1861        let json_data_struct = r#"{
1862        "value": "test",
1863        "unit": {
1864            "factor_to_si_unit": 1.0,
1865            "offset_to_si_unit": 0.0
1866        }
1867        }"#;
1868        let deserialized_struct: ComParamSimpleValue =
1869            serde_json::from_str(json_data_struct).unwrap();
1870        assert_eq!(deserialized_struct.value, "test");
1871        let unit = deserialized_struct.unit.unwrap();
1872        assert_eq!(unit.factor_to_si_unit.unwrap(), 1.0);
1873        assert_eq!(unit.offset_to_si_unit.unwrap(), 0.0);
1874
1875        let json_data_struct = r#"{
1876        "value": "test",
1877        "unit": {
1878            "factor_to_si_unit": 1.0
1879        }
1880        }"#;
1881        let deserialized_struct: ComParamSimpleValue =
1882            serde_json::from_str(json_data_struct).unwrap();
1883        assert_eq!(deserialized_struct.value, "test");
1884        let unit = deserialized_struct.unit.unwrap();
1885        assert_eq!(unit.factor_to_si_unit.unwrap(), 1.0);
1886        assert!(unit.offset_to_si_unit.is_none());
1887
1888        let json_data_struct = r#"{"value": "test"}"#;
1889        let deserialized_struct: ComParamSimpleValue =
1890            serde_json::from_str(json_data_struct).unwrap();
1891        assert_eq!(deserialized_struct.value, "test");
1892        assert!(deserialized_struct.unit.is_none());
1893
1894        let json_data_struct = r#"{"unit": {"factor_to_si_unit": 1.0}}"#;
1895        assert!(serde_json::from_str::<ComParamSimpleValue>(json_data_struct).is_err());
1896    }
1897
1898    mod ecu_operations_collection {
1899        use aide::UseApi;
1900        use axum::{extract::State, http::StatusCode};
1901        use axum_extra::extract::WithRejection;
1902        use cda_interfaces::{
1903            datatypes::ComponentOperationsInfo, file_manager::mock::MockFileManager,
1904            mock::MockUdsEcu,
1905        };
1906        use cda_plugin_security::{Secured, mock::TestSecurityPlugin};
1907        use sovd_interfaces::components::ecu::operations::OperationCollectionItem;
1908
1909        use super::super::*;
1910        use crate::sovd::tests::create_test_webserver_state;
1911
1912        #[tokio::test]
1913        async fn test_get_operations_returns_empty_list() {
1914            let ecu_name = "TestECU".to_string();
1915            let mut mock_uds = MockUdsEcu::new();
1916            let mock_file_manager = MockFileManager::new();
1917
1918            mock_uds
1919                .expect_get_components_operations_info()
1920                .withf(|ecu, _| ecu == "TestECU")
1921                .times(1)
1922                .returning(|_, _| Ok(vec![]));
1923
1924            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
1925                ecu_name,
1926                mock_uds,
1927                mock_file_manager,
1928            );
1929
1930            let response = get::<MockUdsEcu, MockFileManager>(
1931                UseApi(
1932                    Secured(Box::new(TestSecurityPlugin)),
1933                    std::marker::PhantomData,
1934                ),
1935                WithRejection(
1936                    axum::extract::Query(sovd_interfaces::IncludeSchemaQuery {
1937                        include_schema: false,
1938                    }),
1939                    std::marker::PhantomData,
1940                ),
1941                State(state),
1942            )
1943            .await;
1944
1945            assert_eq!(response.status(), StatusCode::OK);
1946            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1947                .await
1948                .unwrap();
1949            let result: sovd_interfaces::Items<OperationCollectionItem> =
1950                serde_json::from_slice(&body).unwrap();
1951            assert!(result.items.is_empty());
1952            assert!(result.schema.is_none());
1953        }
1954
1955        #[tokio::test]
1956        async fn test_get_operations_returns_items() {
1957            let ecu_name = "TestECU".to_string();
1958            let mut mock_uds = MockUdsEcu::new();
1959            let mock_file_manager = MockFileManager::new();
1960
1961            mock_uds
1962                .expect_get_components_operations_info()
1963                .withf(|ecu, _| ecu == "TestECU")
1964                .times(1)
1965                .returning(|_, _| {
1966                    Ok(vec![
1967                        ComponentOperationsInfo {
1968                            id: "CalibrateSensor".to_string(),
1969                            name: "Calibrate Sensor".to_string(),
1970                            has_stop: true,
1971                            has_request_results: true,
1972                        },
1973                        ComponentOperationsInfo {
1974                            id: "RunSelfTest".to_string(),
1975                            name: "Run Self Test".to_string(),
1976                            has_stop: false,
1977                            has_request_results: false,
1978                        },
1979                    ])
1980                });
1981
1982            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
1983                ecu_name,
1984                mock_uds,
1985                mock_file_manager,
1986            );
1987
1988            let response = get::<MockUdsEcu, MockFileManager>(
1989                UseApi(
1990                    Secured(Box::new(TestSecurityPlugin)),
1991                    std::marker::PhantomData,
1992                ),
1993                WithRejection(
1994                    axum::extract::Query(sovd_interfaces::IncludeSchemaQuery {
1995                        include_schema: false,
1996                    }),
1997                    std::marker::PhantomData,
1998                ),
1999                State(state),
2000            )
2001            .await;
2002
2003            assert_eq!(response.status(), StatusCode::OK);
2004            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2005                .await
2006                .unwrap();
2007            let result: sovd_interfaces::Items<OperationCollectionItem> =
2008                serde_json::from_slice(&body).unwrap();
2009            assert_eq!(result.items.len(), 2);
2010
2011            let first = result.items.first().expect("Expected at least one item");
2012            assert_eq!(first.id, "CalibrateSensor");
2013            assert_eq!(first.name, "Calibrate Sensor");
2014            assert!(!first.proximity_proof_required);
2015            assert!(first.asynchronous_execution);
2016
2017            let second = result.items.get(1).expect("Expected a second item");
2018            assert_eq!(second.id, "RunSelfTest");
2019            assert!(!second.proximity_proof_required);
2020            assert!(!second.asynchronous_execution);
2021        }
2022
2023        #[tokio::test]
2024        async fn test_get_operations_with_schema() {
2025            let ecu_name = "TestECU".to_string();
2026            let mut mock_uds = MockUdsEcu::new();
2027            let mock_file_manager = MockFileManager::new();
2028
2029            mock_uds
2030                .expect_get_components_operations_info()
2031                .times(1)
2032                .returning(|_, _| Ok(vec![]));
2033
2034            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2035                ecu_name,
2036                mock_uds,
2037                mock_file_manager,
2038            );
2039
2040            let response = get::<MockUdsEcu, MockFileManager>(
2041                UseApi(
2042                    Secured(Box::new(TestSecurityPlugin)),
2043                    std::marker::PhantomData,
2044                ),
2045                WithRejection(
2046                    axum::extract::Query(sovd_interfaces::IncludeSchemaQuery {
2047                        include_schema: true,
2048                    }),
2049                    std::marker::PhantomData,
2050                ),
2051                State(state),
2052            )
2053            .await;
2054
2055            assert_eq!(response.status(), StatusCode::OK);
2056            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2057                .await
2058                .unwrap();
2059            let result: sovd_interfaces::Items<OperationCollectionItem> =
2060                serde_json::from_slice(&body).unwrap();
2061            assert!(
2062                result.schema.is_some(),
2063                "Schema should be included when requested"
2064            );
2065        }
2066    }
2067
2068    mod service_executions {
2069        use std::sync::Arc;
2070
2071        use aide::UseApi;
2072        use axum::{
2073            extract::{Path, Query, State},
2074            http::StatusCode,
2075        };
2076        use axum_extra::extract::WithRejection;
2077        use cda_interfaces::{
2078            DiagCommType, DiagServiceError,
2079            diagservices::{
2080                DiagServiceJsonResponse, DiagServiceResponseType, mock::MockDiagServiceResponse,
2081            },
2082            file_manager::mock::MockFileManager,
2083            mock::MockUdsEcu,
2084        };
2085        use cda_plugin_security::{Secured, mock::TestSecurityPlugin};
2086        use indexmap::IndexMap;
2087        use sovd_interfaces::{
2088            common::operations::OperationIdItem, components::ecu::operations::ExecutionStatus,
2089        };
2090
2091        use super::super::service::{executions as handlers, executions::id as id_handlers};
2092        use crate::sovd::{
2093            ServiceExecution, locks::insert_test_ecu_lock, tests::create_test_webserver_state,
2094        };
2095
2096        fn make_json_response(data: serde_json::Value) -> MockDiagServiceResponse {
2097            let mut resp = MockDiagServiceResponse::new();
2098            resp.expect_response_type()
2099                .returning(|| DiagServiceResponseType::Positive);
2100            resp.expect_is_empty().returning(|| false);
2101            resp.expect_into_json().return_once(move || {
2102                Ok(DiagServiceJsonResponse {
2103                    data,
2104                    errors: vec![],
2105                })
2106            });
2107            resp
2108        }
2109
2110        fn make_empty_positive_response() -> MockDiagServiceResponse {
2111            let mut resp = MockDiagServiceResponse::new();
2112            resp.expect_response_type()
2113                .returning(|| DiagServiceResponseType::Positive);
2114            resp.expect_is_empty().returning(|| true);
2115            resp
2116        }
2117
2118        fn make_negative_response() -> MockDiagServiceResponse {
2119            let mut resp = MockDiagServiceResponse::new();
2120            resp.expect_response_type()
2121                .returning(|| DiagServiceResponseType::Negative);
2122            resp.expect_is_empty().returning(|| false);
2123            resp.expect_as_nrc().returning(|| {
2124                Ok(cda_interfaces::diagservices::MappedNRC {
2125                    code: Some(0x22),
2126                    description: Some("conditionsNotCorrect".to_string()),
2127                    sid: None,
2128                })
2129            });
2130            resp
2131        }
2132
2133        #[tokio::test]
2134        async fn test_list_executions_empty() {
2135            let ecu_name = "TestECU".to_string();
2136            let mock_uds = MockUdsEcu::new();
2137            let mock_file_manager = MockFileManager::new();
2138            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2139                ecu_name,
2140                mock_uds,
2141                mock_file_manager,
2142            );
2143
2144            let response = handlers::get::<MockUdsEcu, MockFileManager>(
2145                UseApi(
2146                    Secured(Box::new(TestSecurityPlugin)),
2147                    std::marker::PhantomData,
2148                ),
2149                Path(handlers::OperationServicePathParam {
2150                    service: "CalibrateSensor".to_string(),
2151                }),
2152                WithRejection(
2153                    Query(
2154                        sovd_interfaces::components::ecu::operations::service::executions::Query {
2155                            include_schema: false,
2156                        },
2157                    ),
2158                    std::marker::PhantomData,
2159                ),
2160                State(state),
2161            )
2162            .await;
2163
2164            assert_eq!(response.status(), StatusCode::OK);
2165            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2166                .await
2167                .unwrap();
2168            let result: sovd_interfaces::Items<OperationIdItem> =
2169                serde_json::from_slice(&body).unwrap();
2170            assert!(result.items.is_empty());
2171        }
2172
2173        #[tokio::test]
2174        async fn test_list_executions_shows_tracked_id() {
2175            let ecu_name = "TestECU".to_string();
2176            let mock_uds = MockUdsEcu::new();
2177            let mock_file_manager = MockFileManager::new();
2178            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2179                ecu_name,
2180                mock_uds,
2181                mock_file_manager,
2182            );
2183
2184            // Pre-populate an execution
2185            let exec_id = uuid::Uuid::new_v4();
2186            state
2187                .service_executions
2188                .write()
2189                .await
2190                .entry("CalibrateSensor".to_string())
2191                .or_default()
2192                .insert(
2193                    exec_id,
2194                    ServiceExecution {
2195                        parameters: serde_json::Map::new(),
2196                        status: ExecutionStatus::Running,
2197                        in_flight: false,
2198                        is_created: true,
2199                    },
2200                );
2201
2202            let response = handlers::get::<MockUdsEcu, MockFileManager>(
2203                UseApi(
2204                    Secured(Box::new(TestSecurityPlugin)),
2205                    std::marker::PhantomData,
2206                ),
2207                Path(handlers::OperationServicePathParam {
2208                    service: "CalibrateSensor".to_string(),
2209                }),
2210                WithRejection(
2211                    Query(
2212                        sovd_interfaces::components::ecu::operations::service::executions::Query {
2213                            include_schema: false,
2214                        },
2215                    ),
2216                    std::marker::PhantomData,
2217                ),
2218                State(state),
2219            )
2220            .await;
2221
2222            assert_eq!(response.status(), StatusCode::OK);
2223            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2224                .await
2225                .unwrap();
2226            let result: sovd_interfaces::Items<OperationIdItem> =
2227                serde_json::from_slice(&body).unwrap();
2228            assert_eq!(result.items.len(), 1);
2229            assert_eq!(
2230                result.items.first().expect("Expected at least one item").id,
2231                exec_id.to_string()
2232            );
2233        }
2234
2235        #[tokio::test]
2236        async fn test_get_execution_by_id_not_found() {
2237            let ecu_name = "TestECU".to_string();
2238            let mock_uds = MockUdsEcu::new();
2239            let mock_file_manager = MockFileManager::new();
2240            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2241                ecu_name,
2242                mock_uds,
2243                mock_file_manager,
2244            );
2245
2246            let unknown_id = uuid::Uuid::new_v4().to_string();
2247            let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2248                UseApi(
2249                    Secured(Box::new(TestSecurityPlugin)),
2250                    std::marker::PhantomData,
2251                ),
2252                Path(id_handlers::ServiceAndIdPathParam {
2253                    service: "CalibrateSensor".to_string(),
2254                    id: unknown_id,
2255                }),
2256                WithRejection(
2257                    Query(
2258                        sovd_interfaces::components::ecu::operations::OperationQuery {
2259                            include_schema: false,
2260                            suppress_service: false,
2261                        },
2262                    ),
2263                    std::marker::PhantomData,
2264                ),
2265                State(state),
2266            )
2267            .await;
2268
2269            assert_eq!(response.status(), StatusCode::NOT_FOUND);
2270        }
2271
2272        #[tokio::test]
2273        async fn test_get_execution_by_id_calls_request_results() {
2274            let ecu_name = "TestECU".to_string();
2275            let mut mock_uds = MockUdsEcu::new();
2276            let mock_file_manager = MockFileManager::new();
2277
2278            // Expect send with subfunction_id = REQUEST_RESULTS (0x03)
2279            mock_uds
2280                .expect_send()
2281                .withf(|ecu, service, _, payload, map_to_json| {
2282                    ecu == "TestECU"
2283                        && service.type_ == DiagCommType::Operations
2284                        && service.subfunction_id
2285                            == Some(cda_interfaces::subfunction_ids::routine::REQUEST_RESULTS)
2286                        && service.lookup_name.is_none()
2287                        && payload.is_none()
2288                        && *map_to_json
2289                })
2290                .times(1)
2291                .returning(|_, _, _, _, _| {
2292                    Ok(make_json_response(serde_json::json!({
2293                        "result": "ok"
2294                    })))
2295                });
2296
2297            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2298                ecu_name,
2299                mock_uds,
2300                mock_file_manager,
2301            );
2302
2303            let exec_id = uuid::Uuid::new_v4();
2304            state
2305                .service_executions
2306                .write()
2307                .await
2308                .entry("CalibrateSensor".to_string())
2309                .or_default()
2310                .insert(
2311                    exec_id,
2312                    ServiceExecution {
2313                        parameters: serde_json::Map::new(),
2314                        status: ExecutionStatus::Running,
2315                        in_flight: false,
2316                        is_created: true,
2317                    },
2318                );
2319
2320            let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2321                UseApi(
2322                    Secured(Box::new(TestSecurityPlugin)),
2323                    std::marker::PhantomData,
2324                ),
2325                Path(id_handlers::ServiceAndIdPathParam {
2326                    service: "CalibrateSensor".to_string(),
2327                    id: exec_id.to_string(),
2328                }),
2329                WithRejection(
2330                    Query(
2331                        sovd_interfaces::components::ecu::operations::OperationQuery {
2332                            include_schema: false,
2333                            suppress_service: false,
2334                        },
2335                    ),
2336                    std::marker::PhantomData,
2337                ),
2338                State(state),
2339            )
2340            .await;
2341
2342            assert_eq!(response.status(), StatusCode::OK);
2343            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2344                .await
2345                .unwrap();
2346            let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2347            assert_eq!(
2348                result.get("status").expect("missing status"),
2349                &serde_json::json!("completed")
2350            );
2351            assert_eq!(
2352                result.get("capability").expect("missing capability"),
2353                &serde_json::json!("execute")
2354            );
2355            assert_eq!(
2356                result
2357                    .get("parameters")
2358                    .expect("missing parameters")
2359                    .get("result")
2360                    .expect("missing result"),
2361                &serde_json::json!("ok")
2362            );
2363        }
2364
2365        #[tokio::test]
2366        async fn test_get_execution_suppress_service_skips_send_returns_stored() {
2367            let ecu_name = "TestECU".to_string();
2368            let mut mock_uds = MockUdsEcu::new();
2369            let mock_file_manager = MockFileManager::new();
2370
2371            // suppress_service=true must skip the UDS send entirely
2372            mock_uds.expect_send().times(0);
2373
2374            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2375                ecu_name,
2376                mock_uds,
2377                mock_file_manager,
2378            );
2379
2380            let exec_id = uuid::Uuid::new_v4();
2381            let stored_params = {
2382                let mut m = serde_json::Map::new();
2383                m.insert("stored".to_string(), serde_json::json!("value"));
2384                m
2385            };
2386            state
2387                .service_executions
2388                .write()
2389                .await
2390                .entry("CalibrateSensor".to_string())
2391                .or_default()
2392                .insert(
2393                    exec_id,
2394                    ServiceExecution {
2395                        parameters: stored_params,
2396                        status: ExecutionStatus::Running,
2397                        in_flight: false,
2398                        is_created: true,
2399                    },
2400                );
2401
2402            let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2403                UseApi(
2404                    Secured(Box::new(TestSecurityPlugin)),
2405                    std::marker::PhantomData,
2406                ),
2407                Path(id_handlers::ServiceAndIdPathParam {
2408                    service: "CalibrateSensor".to_string(),
2409                    id: exec_id.to_string(),
2410                }),
2411                WithRejection(
2412                    Query(
2413                        sovd_interfaces::components::ecu::operations::OperationQuery {
2414                            include_schema: false,
2415                            suppress_service: true,
2416                        },
2417                    ),
2418                    std::marker::PhantomData,
2419                ),
2420                State(state),
2421            )
2422            .await;
2423
2424            // suppress_service=true -> should return 200 with stored params, no UDS send
2425            assert_eq!(response.status(), StatusCode::OK);
2426            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2427                .await
2428                .unwrap();
2429            let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2430            assert_eq!(
2431                result.get("status").expect("missing status"),
2432                &serde_json::json!("running")
2433            );
2434            assert_eq!(
2435                result
2436                    .get("parameters")
2437                    .expect("missing parameters")
2438                    .get("stored")
2439                    .expect("missing stored"),
2440                &serde_json::json!("value")
2441            );
2442        }
2443
2444        #[tokio::test]
2445        async fn test_get_execution_not_found_without_suppress_returns_error() {
2446            let ecu_name = "TestECU".to_string();
2447            let mut mock_uds = MockUdsEcu::new();
2448            let mock_file_manager = MockFileManager::new();
2449
2450            mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
2451                Err(DiagServiceError::NotFound(
2452                    "CalibrateSensor_RequestResults not found".to_string(),
2453                ))
2454            });
2455
2456            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2457                ecu_name,
2458                mock_uds,
2459                mock_file_manager,
2460            );
2461
2462            let exec_id = uuid::Uuid::new_v4();
2463            state
2464                .service_executions
2465                .write()
2466                .await
2467                .entry("CalibrateSensor".to_string())
2468                .or_default()
2469                .insert(
2470                    exec_id,
2471                    ServiceExecution {
2472                        parameters: serde_json::Map::new(),
2473                        status: ExecutionStatus::Running,
2474                        in_flight: false,
2475                        is_created: true,
2476                    },
2477                );
2478
2479            let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2480                UseApi(
2481                    Secured(Box::new(TestSecurityPlugin)),
2482                    std::marker::PhantomData,
2483                ),
2484                Path(id_handlers::ServiceAndIdPathParam {
2485                    service: "CalibrateSensor".to_string(),
2486                    id: exec_id.to_string(),
2487                }),
2488                WithRejection(
2489                    Query(
2490                        sovd_interfaces::components::ecu::operations::OperationQuery {
2491                            include_schema: false,
2492                            suppress_service: false,
2493                        },
2494                    ),
2495                    std::marker::PhantomData,
2496                ),
2497                State(state),
2498            )
2499            .await;
2500
2501            // suppress_service=false -> NotFound from UDS should propagate as error
2502            assert!(response.status().is_client_error() || response.status().is_server_error());
2503        }
2504
2505        #[tokio::test]
2506        async fn test_delete_execution_not_found() {
2507            let ecu_name = "TestECU".to_string();
2508            let mock_uds = MockUdsEcu::new();
2509            let mock_file_manager = MockFileManager::new();
2510            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2511                ecu_name.clone(),
2512                mock_uds,
2513                mock_file_manager,
2514            );
2515            insert_test_ecu_lock(&state.locks, &ecu_name).await;
2516
2517            let unknown_id = uuid::Uuid::new_v4().to_string();
2518            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2519                UseApi(
2520                    Secured(Box::new(TestSecurityPlugin)),
2521                    std::marker::PhantomData,
2522                ),
2523                Path(id_handlers::ServiceAndIdPathParam {
2524                    service: "CalibrateSensor".to_string(),
2525                    id: unknown_id,
2526                }),
2527                WithRejection(
2528                    Query(
2529                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2530                            include_schema: false,
2531                            suppress_service: false,
2532                            force: false,
2533                        },
2534                    ),
2535                    std::marker::PhantomData,
2536                ),
2537                State(state),
2538            )
2539            .await;
2540
2541            assert_eq!(response.status(), StatusCode::NOT_FOUND);
2542        }
2543
2544        #[tokio::test]
2545        async fn test_delete_execution_calls_stop() {
2546            let ecu_name = "TestECU".to_string();
2547            let mut mock_uds = MockUdsEcu::new();
2548            let mock_file_manager = MockFileManager::new();
2549
2550            // Expect send with subfunction_id = STOP (0x02)
2551            mock_uds
2552                .expect_send()
2553                .withf(|ecu, service, _, _, _| {
2554                    ecu == "TestECU"
2555                        && service.type_ == DiagCommType::Operations
2556                        && service.subfunction_id
2557                            == Some(cda_interfaces::subfunction_ids::routine::STOP)
2558                        && service.lookup_name.is_none()
2559                })
2560                .times(1)
2561                .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
2562
2563            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2564                ecu_name,
2565                mock_uds,
2566                mock_file_manager,
2567            );
2568            insert_test_ecu_lock(&state.locks, "TestECU").await;
2569
2570            let exec_id = uuid::Uuid::new_v4();
2571            state
2572                .service_executions
2573                .write()
2574                .await
2575                .entry("CalibrateSensor".to_string())
2576                .or_default()
2577                .insert(
2578                    exec_id,
2579                    ServiceExecution {
2580                        parameters: serde_json::Map::new(),
2581                        status: ExecutionStatus::Running,
2582                        in_flight: false,
2583                        is_created: true,
2584                    },
2585                );
2586
2587            // Keep a reference to service_executions so we can verify after consuming state
2588            let service_executions_ref = Arc::clone(&state.service_executions);
2589
2590            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2591                UseApi(
2592                    Secured(Box::new(TestSecurityPlugin)),
2593                    std::marker::PhantomData,
2594                ),
2595                Path(id_handlers::ServiceAndIdPathParam {
2596                    service: "CalibrateSensor".to_string(),
2597                    id: exec_id.to_string(),
2598                }),
2599                WithRejection(
2600                    Query(
2601                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2602                            include_schema: false,
2603                            suppress_service: false,
2604                            force: false,
2605                        },
2606                    ),
2607                    std::marker::PhantomData,
2608                ),
2609                State(state),
2610            )
2611            .await;
2612
2613            assert_eq!(response.status(), StatusCode::NO_CONTENT);
2614            // Verify execution was removed
2615            assert!(
2616                service_executions_ref
2617                    .read()
2618                    .await
2619                    .values()
2620                    .all(IndexMap::is_empty)
2621            );
2622        }
2623
2624        #[tokio::test]
2625        async fn test_delete_execution_stop_with_data_returns_200_stopped() {
2626            let ecu_name = "TestECU".to_string();
2627            let mut mock_uds = MockUdsEcu::new();
2628            let mock_file_manager = MockFileManager::new();
2629
2630            // ECU returns a non-empty positive response from Stop
2631            mock_uds
2632                .expect_send()
2633                .withf(|ecu, service, _, _, map_to_json| {
2634                    ecu == "TestECU"
2635                        && service.subfunction_id
2636                            == Some(cda_interfaces::subfunction_ids::routine::STOP)
2637                        && *map_to_json
2638                })
2639                .times(1)
2640                .returning(|_, _, _, _, _| {
2641                    Ok(make_json_response(serde_json::json!({
2642                        "stop_result": "ok"
2643                    })))
2644                });
2645
2646            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2647                ecu_name,
2648                mock_uds,
2649                mock_file_manager,
2650            );
2651            insert_test_ecu_lock(&state.locks, "TestECU").await;
2652
2653            let exec_id = uuid::Uuid::new_v4();
2654            state
2655                .service_executions
2656                .write()
2657                .await
2658                .entry("CalibrateSensor".to_string())
2659                .or_default()
2660                .insert(
2661                    exec_id,
2662                    ServiceExecution {
2663                        parameters: serde_json::Map::new(),
2664                        status: ExecutionStatus::Running,
2665                        in_flight: false,
2666                        is_created: true,
2667                    },
2668                );
2669
2670            let service_executions_ref = Arc::clone(&state.service_executions);
2671
2672            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2673                UseApi(
2674                    Secured(Box::new(TestSecurityPlugin)),
2675                    std::marker::PhantomData,
2676                ),
2677                Path(id_handlers::ServiceAndIdPathParam {
2678                    service: "CalibrateSensor".to_string(),
2679                    id: exec_id.to_string(),
2680                }),
2681                WithRejection(
2682                    Query(
2683                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2684                            include_schema: false,
2685                            suppress_service: false,
2686                            force: false,
2687                        },
2688                    ),
2689                    std::marker::PhantomData,
2690                ),
2691                State(state),
2692            )
2693            .await;
2694
2695            // ECU returned data -> 200 with status=stopped and the parameters
2696            assert_eq!(response.status(), StatusCode::OK);
2697            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2698                .await
2699                .unwrap();
2700            let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2701            assert_eq!(
2702                result.get("status").expect("missing status"),
2703                &serde_json::json!("stopped")
2704            );
2705            assert_eq!(
2706                result
2707                    .get("parameters")
2708                    .expect("missing parameters")
2709                    .get("stop_result")
2710                    .expect("missing stop_result"),
2711                &serde_json::json!("ok")
2712            );
2713            // Execution must be removed regardless
2714            assert!(
2715                service_executions_ref
2716                    .read()
2717                    .await
2718                    .values()
2719                    .all(IndexMap::is_empty)
2720            );
2721        }
2722
2723        #[tokio::test]
2724        async fn test_delete_execution_stop_with_null_json_returns_200_empty_parameters() {
2725            // Stop maps to JSON Null -> 200 with empty parameters (user-requested extension)
2726            let ecu_name = "TestECU".to_string();
2727            let mut mock_uds = MockUdsEcu::new();
2728            let mock_file_manager = MockFileManager::new();
2729
2730            mock_uds
2731                .expect_send()
2732                .withf(|ecu, service, _, _, map_to_json| {
2733                    ecu == "TestECU"
2734                        && service.subfunction_id
2735                            == Some(cda_interfaces::subfunction_ids::routine::STOP)
2736                        && *map_to_json
2737                })
2738                .times(1)
2739                .returning(|_, _, _, _, _| Ok(make_json_response(serde_json::Value::Null)));
2740
2741            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2742                ecu_name,
2743                mock_uds,
2744                mock_file_manager,
2745            );
2746            insert_test_ecu_lock(&state.locks, "TestECU").await;
2747
2748            let exec_id = uuid::Uuid::new_v4();
2749            state
2750                .service_executions
2751                .write()
2752                .await
2753                .entry("CalibrateSensor".to_string())
2754                .or_default()
2755                .insert(
2756                    exec_id,
2757                    ServiceExecution {
2758                        parameters: serde_json::Map::new(),
2759                        status: ExecutionStatus::Running,
2760                        in_flight: false,
2761                        is_created: true,
2762                    },
2763                );
2764
2765            let service_executions_ref = Arc::clone(&state.service_executions);
2766
2767            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2768                UseApi(
2769                    Secured(Box::new(TestSecurityPlugin)),
2770                    std::marker::PhantomData,
2771                ),
2772                Path(id_handlers::ServiceAndIdPathParam {
2773                    service: "CalibrateSensor".to_string(),
2774                    id: exec_id.to_string(),
2775                }),
2776                WithRejection(
2777                    Query(
2778                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2779                            include_schema: false,
2780                            suppress_service: false,
2781                            force: false,
2782                        },
2783                    ),
2784                    std::marker::PhantomData,
2785                ),
2786                State(state),
2787            )
2788            .await;
2789
2790            assert_eq!(response.status(), StatusCode::OK);
2791            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2792                .await
2793                .unwrap();
2794            let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2795            assert_eq!(
2796                result.get("status").expect("missing status"),
2797                &serde_json::json!("stopped")
2798            );
2799            // parameters: None when empty -> field is omitted from JSON
2800            assert!(
2801                result.get("parameters").is_none(),
2802                "parameters should be absent when Stop returns Null"
2803            );
2804            assert!(
2805                service_executions_ref
2806                    .read()
2807                    .await
2808                    .values()
2809                    .all(IndexMap::is_empty)
2810            );
2811        }
2812
2813        #[tokio::test]
2814        async fn test_delete_execution_stop_non_object_json_returns_200_stopped_with_error() {
2815            // Stop maps to a non-object JSON value (e.g. a string) -> 200 stopped, error surfaced
2816            let ecu_name = "TestECU".to_string();
2817            let mut mock_uds = MockUdsEcu::new();
2818            let mock_file_manager = MockFileManager::new();
2819
2820            mock_uds
2821                .expect_send()
2822                .withf(|ecu, service, _, _, map_to_json| {
2823                    ecu == "TestECU"
2824                        && service.subfunction_id
2825                            == Some(cda_interfaces::subfunction_ids::routine::STOP)
2826                        && *map_to_json
2827                })
2828                .times(1)
2829                .returning(|_, _, _, _, _| {
2830                    Ok(make_json_response(serde_json::json!("unexpected_string")))
2831                });
2832
2833            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2834                ecu_name,
2835                mock_uds,
2836                mock_file_manager,
2837            );
2838            insert_test_ecu_lock(&state.locks, "TestECU").await;
2839
2840            let exec_id = uuid::Uuid::new_v4();
2841            state
2842                .service_executions
2843                .write()
2844                .await
2845                .entry("CalibrateSensor".to_string())
2846                .or_default()
2847                .insert(
2848                    exec_id,
2849                    ServiceExecution {
2850                        parameters: serde_json::Map::new(),
2851                        status: ExecutionStatus::Running,
2852                        in_flight: false,
2853                        is_created: true,
2854                    },
2855                );
2856
2857            let service_executions_ref = Arc::clone(&state.service_executions);
2858
2859            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2860                UseApi(
2861                    Secured(Box::new(TestSecurityPlugin)),
2862                    std::marker::PhantomData,
2863                ),
2864                Path(id_handlers::ServiceAndIdPathParam {
2865                    service: "CalibrateSensor".to_string(),
2866                    id: exec_id.to_string(),
2867                }),
2868                WithRejection(
2869                    Query(
2870                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2871                            include_schema: false,
2872                            suppress_service: false,
2873                            force: false,
2874                        },
2875                    ),
2876                    std::marker::PhantomData,
2877                ),
2878                State(state),
2879            )
2880            .await;
2881
2882            assert_eq!(response.status(), StatusCode::OK);
2883            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2884                .await
2885                .unwrap();
2886            let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2887            assert_eq!(
2888                result.get("status").expect("missing status"),
2889                &serde_json::json!("stopped")
2890            );
2891            // error list (field name "error" per AsyncGetByIdResponse) must be non-empty
2892            let errors = result.get("error").expect("missing error field");
2893            assert!(errors.is_array() && !errors.as_array().unwrap().is_empty());
2894            assert!(
2895                service_executions_ref
2896                    .read()
2897                    .await
2898                    .values()
2899                    .all(IndexMap::is_empty)
2900            );
2901        }
2902
2903        #[tokio::test]
2904        async fn test_delete_execution_stop_into_json_error_returns_200_stopped_with_error() {
2905            // Stop response cannot be parsed (into_json fails) -> 200 stopped, error surfaced
2906            let ecu_name = "TestECU".to_string();
2907            let mut mock_uds = MockUdsEcu::new();
2908            let mock_file_manager = MockFileManager::new();
2909
2910            mock_uds
2911                .expect_send()
2912                .withf(|ecu, service, _, _, map_to_json| {
2913                    ecu == "TestECU"
2914                        && service.subfunction_id
2915                            == Some(cda_interfaces::subfunction_ids::routine::STOP)
2916                        && *map_to_json
2917                })
2918                .times(1)
2919                .returning(|_, _, _, _, _| {
2920                    let mut resp = MockDiagServiceResponse::new();
2921                    resp.expect_response_type()
2922                        .returning(|| DiagServiceResponseType::Positive);
2923                    resp.expect_is_empty().returning(|| false);
2924                    resp.expect_into_json().return_once(|| {
2925                        Err(DiagServiceError::BadPayload(
2926                            "simulated Stop parse failure".to_string(),
2927                        ))
2928                    });
2929                    Ok(resp)
2930                });
2931
2932            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2933                ecu_name,
2934                mock_uds,
2935                mock_file_manager,
2936            );
2937            insert_test_ecu_lock(&state.locks, "TestECU").await;
2938
2939            let exec_id = uuid::Uuid::new_v4();
2940            state
2941                .service_executions
2942                .write()
2943                .await
2944                .entry("CalibrateSensor".to_string())
2945                .or_default()
2946                .insert(
2947                    exec_id,
2948                    ServiceExecution {
2949                        parameters: serde_json::Map::new(),
2950                        status: ExecutionStatus::Running,
2951                        in_flight: false,
2952                        is_created: true,
2953                    },
2954                );
2955
2956            let service_executions_ref = Arc::clone(&state.service_executions);
2957
2958            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2959                UseApi(
2960                    Secured(Box::new(TestSecurityPlugin)),
2961                    std::marker::PhantomData,
2962                ),
2963                Path(id_handlers::ServiceAndIdPathParam {
2964                    service: "CalibrateSensor".to_string(),
2965                    id: exec_id.to_string(),
2966                }),
2967                WithRejection(
2968                    Query(
2969                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2970                            include_schema: false,
2971                            suppress_service: false,
2972                            force: false,
2973                        },
2974                    ),
2975                    std::marker::PhantomData,
2976                ),
2977                State(state),
2978            )
2979            .await;
2980
2981            assert_eq!(response.status(), StatusCode::OK);
2982            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2983                .await
2984                .unwrap();
2985            let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2986            assert_eq!(
2987                result.get("status").expect("missing status"),
2988                &serde_json::json!("stopped")
2989            );
2990            let errors = result.get("error").expect("missing error field");
2991            assert!(errors.is_array() && !errors.as_array().unwrap().is_empty());
2992            assert!(
2993                service_executions_ref
2994                    .read()
2995                    .await
2996                    .values()
2997                    .all(IndexMap::is_empty)
2998            );
2999        }
3000
3001        #[tokio::test]
3002        async fn test_delete_execution_force_removes_on_uds_error() {
3003            let ecu_name = "TestECU".to_string();
3004            let mut mock_uds = MockUdsEcu::new();
3005            let mock_file_manager = MockFileManager::new();
3006
3007            // UDS returns an error (non-NotFound)
3008            mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3009                Err(DiagServiceError::SendFailed("timeout".to_string()))
3010            });
3011
3012            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3013                ecu_name,
3014                mock_uds,
3015                mock_file_manager,
3016            );
3017            insert_test_ecu_lock(&state.locks, "TestECU").await;
3018
3019            let exec_id = uuid::Uuid::new_v4();
3020            state
3021                .service_executions
3022                .write()
3023                .await
3024                .entry("CalibrateSensor".to_string())
3025                .or_default()
3026                .insert(
3027                    exec_id,
3028                    ServiceExecution {
3029                        parameters: serde_json::Map::new(),
3030                        status: ExecutionStatus::Running,
3031                        in_flight: false,
3032                        is_created: true,
3033                    },
3034                );
3035
3036            // Keep a reference to service_executions so we can verify after consuming state
3037            let service_executions_ref = Arc::clone(&state.service_executions);
3038
3039            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3040                UseApi(
3041                    Secured(Box::new(TestSecurityPlugin)),
3042                    std::marker::PhantomData,
3043                ),
3044                Path(id_handlers::ServiceAndIdPathParam {
3045                    service: "CalibrateSensor".to_string(),
3046                    id: exec_id.to_string(),
3047                }),
3048                WithRejection(
3049                    Query(
3050                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3051                            include_schema: false,
3052                            suppress_service: false,
3053                            force: true,
3054                        },
3055                    ),
3056                    std::marker::PhantomData,
3057                ),
3058                State(state),
3059            )
3060            .await;
3061
3062            // force=true -> removes execution even on error
3063            assert_eq!(response.status(), StatusCode::NO_CONTENT);
3064            assert!(
3065                service_executions_ref
3066                    .read()
3067                    .await
3068                    .values()
3069                    .all(IndexMap::is_empty)
3070            );
3071        }
3072
3073        #[tokio::test]
3074        async fn test_delete_execution_force_removes_on_negative_response() {
3075            let ecu_name = "TestECU".to_string();
3076            let mut mock_uds = MockUdsEcu::new();
3077            let mock_file_manager = MockFileManager::new();
3078
3079            mock_uds
3080                .expect_send()
3081                .times(1)
3082                .returning(|_, _, _, _, _| Ok(make_negative_response()));
3083
3084            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3085                ecu_name,
3086                mock_uds,
3087                mock_file_manager,
3088            );
3089            insert_test_ecu_lock(&state.locks, "TestECU").await;
3090
3091            let exec_id = uuid::Uuid::new_v4();
3092            state
3093                .service_executions
3094                .write()
3095                .await
3096                .entry("CalibrateSensor".to_string())
3097                .or_default()
3098                .insert(
3099                    exec_id,
3100                    ServiceExecution {
3101                        parameters: serde_json::Map::new(),
3102                        status: ExecutionStatus::Running,
3103                        in_flight: false,
3104                        is_created: true,
3105                    },
3106                );
3107
3108            let service_executions_ref = Arc::clone(&state.service_executions);
3109
3110            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3111                UseApi(
3112                    Secured(Box::new(TestSecurityPlugin)),
3113                    std::marker::PhantomData,
3114                ),
3115                Path(id_handlers::ServiceAndIdPathParam {
3116                    service: "CalibrateSensor".to_string(),
3117                    id: exec_id.to_string(),
3118                }),
3119                WithRejection(
3120                    Query(
3121                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3122                            include_schema: false,
3123                            suppress_service: false,
3124                            force: true,
3125                        },
3126                    ),
3127                    std::marker::PhantomData,
3128                ),
3129                State(state),
3130            )
3131            .await;
3132
3133            // force=true -> removes execution even on negative ECU response
3134            assert_eq!(response.status(), StatusCode::NO_CONTENT);
3135            assert!(
3136                service_executions_ref
3137                    .read()
3138                    .await
3139                    .values()
3140                    .all(IndexMap::is_empty)
3141            );
3142        }
3143
3144        #[tokio::test]
3145        async fn test_delete_execution_without_force_returns_error_on_uds_failure() {
3146            let ecu_name = "TestECU".to_string();
3147            let mut mock_uds = MockUdsEcu::new();
3148            let mock_file_manager = MockFileManager::new();
3149
3150            mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3151                Err(DiagServiceError::SendFailed("timeout".to_string()))
3152            });
3153
3154            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3155                ecu_name,
3156                mock_uds,
3157                mock_file_manager,
3158            );
3159            insert_test_ecu_lock(&state.locks, "TestECU").await;
3160
3161            let exec_id = uuid::Uuid::new_v4();
3162            state
3163                .service_executions
3164                .write()
3165                .await
3166                .entry("CalibrateSensor".to_string())
3167                .or_default()
3168                .insert(
3169                    exec_id,
3170                    ServiceExecution {
3171                        parameters: serde_json::Map::new(),
3172                        status: ExecutionStatus::Running,
3173                        in_flight: false,
3174                        is_created: true,
3175                    },
3176                );
3177
3178            // Keep a reference to service_executions so we can verify after consuming state
3179            let service_executions_ref = Arc::clone(&state.service_executions);
3180
3181            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3182                UseApi(
3183                    Secured(Box::new(TestSecurityPlugin)),
3184                    std::marker::PhantomData,
3185                ),
3186                Path(id_handlers::ServiceAndIdPathParam {
3187                    service: "CalibrateSensor".to_string(),
3188                    id: exec_id.to_string(),
3189                }),
3190                WithRejection(
3191                    Query(
3192                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3193                            include_schema: false,
3194                            suppress_service: false,
3195                            force: false,
3196                        },
3197                    ),
3198                    std::marker::PhantomData,
3199                ),
3200                State(state),
3201            )
3202            .await;
3203
3204            // force=false -> error should be returned, execution should remain
3205            assert!(response.status().is_client_error() || response.status().is_server_error());
3206            assert_eq!(service_executions_ref.read().await.len(), 1);
3207        }
3208
3209        #[tokio::test]
3210        async fn test_delete_execution_negative_response_resets_in_flight() {
3211            let ecu_name = "TestECU".to_string();
3212            let mut mock_uds = MockUdsEcu::new();
3213            let mock_file_manager = MockFileManager::new();
3214
3215            mock_uds
3216                .expect_send()
3217                .times(1)
3218                .returning(|_, _, _, _, _| Ok(make_negative_response()));
3219
3220            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3221                ecu_name,
3222                mock_uds,
3223                mock_file_manager,
3224            );
3225            insert_test_ecu_lock(&state.locks, "TestECU").await;
3226
3227            let exec_id = uuid::Uuid::new_v4();
3228            state
3229                .service_executions
3230                .write()
3231                .await
3232                .entry("CalibrateSensor".to_string())
3233                .or_default()
3234                .insert(
3235                    exec_id,
3236                    ServiceExecution {
3237                        parameters: serde_json::Map::new(),
3238                        status: ExecutionStatus::Running,
3239                        in_flight: false,
3240                        is_created: true,
3241                    },
3242                );
3243
3244            let service_executions_ref = Arc::clone(&state.service_executions);
3245
3246            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3247                UseApi(
3248                    Secured(Box::new(TestSecurityPlugin)),
3249                    std::marker::PhantomData,
3250                ),
3251                Path(id_handlers::ServiceAndIdPathParam {
3252                    service: "CalibrateSensor".to_string(),
3253                    id: exec_id.to_string(),
3254                }),
3255                WithRejection(
3256                    Query(
3257                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3258                            include_schema: false,
3259                            suppress_service: false,
3260                            force: false,
3261                        },
3262                    ),
3263                    std::marker::PhantomData,
3264                ),
3265                State(state),
3266            )
3267            .await;
3268
3269            // Negative ECU response without force -> error returned, in_flight reset
3270            assert!(response.status().is_client_error() || response.status().is_server_error());
3271            let guard = service_executions_ref.read().await;
3272            let exec = guard
3273                .get("CalibrateSensor")
3274                .and_then(|m| m.get(&exec_id))
3275                .expect("execution should still exist");
3276            assert!(!exec.in_flight, "in_flight should be reset to false");
3277        }
3278
3279        #[tokio::test]
3280        async fn test_delete_execution_suppress_service_removes_on_not_found() {
3281            let ecu_name = "TestECU".to_string();
3282            let mut mock_uds = MockUdsEcu::new();
3283            let mock_file_manager = MockFileManager::new();
3284
3285            mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3286                Err(DiagServiceError::NotFound(
3287                    "CalibrateSensor_Stop not found".to_string(),
3288                ))
3289            });
3290
3291            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3292                ecu_name,
3293                mock_uds,
3294                mock_file_manager,
3295            );
3296            insert_test_ecu_lock(&state.locks, "TestECU").await;
3297
3298            let exec_id = uuid::Uuid::new_v4();
3299            state
3300                .service_executions
3301                .write()
3302                .await
3303                .entry("CalibrateSensor".to_string())
3304                .or_default()
3305                .insert(
3306                    exec_id,
3307                    ServiceExecution {
3308                        parameters: serde_json::Map::new(),
3309                        status: ExecutionStatus::Running,
3310                        in_flight: false,
3311                        is_created: true,
3312                    },
3313                );
3314
3315            // Keep a reference to service_executions so we can verify after consuming state
3316            let service_executions_ref = Arc::clone(&state.service_executions);
3317
3318            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3319                UseApi(
3320                    Secured(Box::new(TestSecurityPlugin)),
3321                    std::marker::PhantomData,
3322                ),
3323                Path(id_handlers::ServiceAndIdPathParam {
3324                    service: "CalibrateSensor".to_string(),
3325                    id: exec_id.to_string(),
3326                }),
3327                WithRejection(
3328                    Query(
3329                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3330                            include_schema: false,
3331                            suppress_service: true,
3332                            force: false,
3333                        },
3334                    ),
3335                    std::marker::PhantomData,
3336                ),
3337                State(state),
3338            )
3339            .await;
3340
3341            // suppress_service=true on NotFound -> removes execution, returns 204
3342            assert_eq!(response.status(), StatusCode::NO_CONTENT);
3343            assert!(
3344                service_executions_ref
3345                    .read()
3346                    .await
3347                    .values()
3348                    .all(IndexMap::is_empty)
3349            );
3350        }
3351
3352        #[tokio::test]
3353        async fn test_get_execution_in_flight_returns_conflict() {
3354            let ecu_name = "TestECU".to_string();
3355            let mock_uds = MockUdsEcu::new();
3356            let mock_file_manager = MockFileManager::new();
3357            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3358                ecu_name,
3359                mock_uds,
3360                mock_file_manager,
3361            );
3362
3363            let exec_id = uuid::Uuid::new_v4();
3364            state
3365                .service_executions
3366                .write()
3367                .await
3368                .entry("CalibrateSensor".to_string())
3369                .or_default()
3370                .insert(
3371                    exec_id,
3372                    ServiceExecution {
3373                        parameters: serde_json::Map::new(),
3374                        status: ExecutionStatus::Running,
3375                        in_flight: true,
3376                        is_created: true,
3377                    },
3378                );
3379
3380            let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
3381                UseApi(
3382                    Secured(Box::new(TestSecurityPlugin)),
3383                    std::marker::PhantomData,
3384                ),
3385                Path(id_handlers::ServiceAndIdPathParam {
3386                    service: "CalibrateSensor".to_string(),
3387                    id: exec_id.to_string(),
3388                }),
3389                WithRejection(
3390                    Query(
3391                        sovd_interfaces::components::ecu::operations::OperationQuery {
3392                            include_schema: false,
3393                            suppress_service: false,
3394                        },
3395                    ),
3396                    std::marker::PhantomData,
3397                ),
3398                State(state),
3399            )
3400            .await;
3401
3402            assert_eq!(response.status(), StatusCode::CONFLICT);
3403        }
3404
3405        #[tokio::test]
3406        async fn test_delete_execution_in_flight_returns_conflict() {
3407            let ecu_name = "TestECU".to_string();
3408            let mock_uds = MockUdsEcu::new();
3409            let mock_file_manager = MockFileManager::new();
3410            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3411                ecu_name.clone(),
3412                mock_uds,
3413                mock_file_manager,
3414            );
3415            insert_test_ecu_lock(&state.locks, &ecu_name).await;
3416
3417            let exec_id = uuid::Uuid::new_v4();
3418            state
3419                .service_executions
3420                .write()
3421                .await
3422                .entry("CalibrateSensor".to_string())
3423                .or_default()
3424                .insert(
3425                    exec_id,
3426                    ServiceExecution {
3427                        parameters: serde_json::Map::new(),
3428                        status: ExecutionStatus::Running,
3429                        in_flight: true,
3430                        is_created: true,
3431                    },
3432                );
3433
3434            let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3435                UseApi(
3436                    Secured(Box::new(TestSecurityPlugin)),
3437                    std::marker::PhantomData,
3438                ),
3439                Path(id_handlers::ServiceAndIdPathParam {
3440                    service: "CalibrateSensor".to_string(),
3441                    id: exec_id.to_string(),
3442                }),
3443                WithRejection(
3444                    Query(
3445                        sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3446                            include_schema: false,
3447                            suppress_service: false,
3448                            force: false,
3449                        },
3450                    ),
3451                    std::marker::PhantomData,
3452                ),
3453                State(state),
3454            )
3455            .await;
3456
3457            assert_eq!(response.status(), StatusCode::CONFLICT);
3458        }
3459
3460        fn make_post_headers() -> axum::http::HeaderMap {
3461            let mut headers = axum::http::HeaderMap::new();
3462            headers.insert(
3463                axum::http::header::CONTENT_TYPE,
3464                axum::http::HeaderValue::from_static("application/json"),
3465            );
3466            headers.insert(
3467                axum::http::header::ACCEPT,
3468                axum::http::HeaderValue::from_static("application/json"),
3469            );
3470            headers
3471        }
3472
3473        #[tokio::test]
3474        async fn test_post_operation_conflict_when_running_execution_exists() {
3475            let ecu_name = "TestECU".to_string();
3476            let mock_uds = MockUdsEcu::new();
3477            let mock_file_manager = MockFileManager::new();
3478
3479            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3480                ecu_name.clone(),
3481                mock_uds,
3482                mock_file_manager,
3483            );
3484
3485            // Pre-populate a running execution for CalibrateSensor
3486            state
3487                .service_executions
3488                .write()
3489                .await
3490                .entry("CalibrateSensor".to_string())
3491                .or_default()
3492                .insert(
3493                    uuid::Uuid::new_v4(),
3494                    ServiceExecution {
3495                        parameters: serde_json::Map::new(),
3496                        status: ExecutionStatus::Running,
3497                        in_flight: false,
3498                        is_created: true,
3499                    },
3500                );
3501
3502            let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3503                handlers::WriteHandlerRequest {
3504                    service: "CalibrateSensor".to_string(),
3505                    headers: make_post_headers(),
3506                    body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3507                },
3508                &ecu_name,
3509                &state.uds,
3510                Arc::clone(&state.service_executions),
3511                Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3512                handlers::WriteHandlerOptions {
3513                    include_schema: false,
3514                    suppress_service: false,
3515                    base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3516                },
3517                &std::sync::atomic::AtomicBool::new(false),
3518            )
3519            .await;
3520
3521            assert_eq!(response.status(), StatusCode::CONFLICT);
3522        }
3523
3524        #[tokio::test]
3525        async fn test_post_operation_no_conflict_for_different_service() {
3526            // An execution running for ServiceA must NOT block ServiceB
3527            let ecu_name = "TestECU".to_string();
3528            let mut mock_uds = MockUdsEcu::new();
3529            let mock_file_manager = MockFileManager::new();
3530
3531            mock_uds
3532                .expect_get_routine_subfunctions()
3533                .times(1)
3534                .returning(|_, _, _| {
3535                    Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3536                        has_stop: false,
3537                        has_request_results: false,
3538                    })
3539                });
3540            mock_uds
3541                .expect_send()
3542                .times(1)
3543                .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
3544
3545            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3546                ecu_name.clone(),
3547                mock_uds,
3548                mock_file_manager,
3549            );
3550
3551            // Pre-populate a running execution for a DIFFERENT service
3552            state
3553                .service_executions
3554                .write()
3555                .await
3556                .entry("OtherService".to_string())
3557                .or_default()
3558                .insert(
3559                    uuid::Uuid::new_v4(),
3560                    ServiceExecution {
3561                        parameters: serde_json::Map::new(),
3562                        status: ExecutionStatus::Running,
3563                        in_flight: false,
3564                        is_created: true,
3565                    },
3566                );
3567
3568            let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3569                handlers::WriteHandlerRequest {
3570                    service: "CalibrateSensor".to_string(),
3571                    headers: make_post_headers(),
3572                    body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3573                },
3574                &ecu_name,
3575                &state.uds,
3576                Arc::clone(&state.service_executions),
3577                Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3578                handlers::WriteHandlerOptions {
3579                    include_schema: false,
3580                    suppress_service: false,
3581                    base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3582                },
3583                &std::sync::atomic::AtomicBool::new(false),
3584            )
3585            .await;
3586
3587            // Different service -> no conflict, should pass through to 200
3588            assert_eq!(response.status(), StatusCode::OK);
3589        }
3590
3591        #[tokio::test]
3592        async fn test_post_operation_service_not_found_returns_404() {
3593            let ecu_name = "TestECU".to_string();
3594            let mut mock_uds = MockUdsEcu::new();
3595            let mock_file_manager = MockFileManager::new();
3596
3597            mock_uds
3598                .expect_get_routine_subfunctions()
3599                .withf(|ecu, svc, _p| ecu == "TestECU" && svc == "CalibrateSensor")
3600                .times(1)
3601                .returning(|_, _, _| {
3602                    Err(DiagServiceError::NotFound(
3603                        "Routine 'CalibrateSensor' not found in ECU description".to_string(),
3604                    ))
3605                });
3606
3607            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3608                ecu_name.clone(),
3609                mock_uds,
3610                mock_file_manager,
3611            );
3612
3613            let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3614                handlers::WriteHandlerRequest {
3615                    service: "CalibrateSensor".to_string(),
3616                    headers: make_post_headers(),
3617                    body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3618                },
3619                &ecu_name,
3620                &state.uds,
3621                Arc::clone(&state.service_executions),
3622                Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3623                handlers::WriteHandlerOptions {
3624                    include_schema: false,
3625                    suppress_service: false,
3626                    base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3627                },
3628                &std::sync::atomic::AtomicBool::new(false),
3629            )
3630            .await;
3631
3632            assert_eq!(response.status(), StatusCode::NOT_FOUND);
3633        }
3634
3635        #[tokio::test]
3636        async fn test_post_operation_sync_returns_200_on_empty_response() {
3637            let ecu_name = "TestECU".to_string();
3638            let mut mock_uds = MockUdsEcu::new();
3639            let mock_file_manager = MockFileManager::new();
3640
3641            mock_uds
3642                .expect_get_routine_subfunctions()
3643                .times(1)
3644                .returning(|_, _, _| {
3645                    Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3646                        has_stop: false,
3647                        has_request_results: false,
3648                    })
3649                });
3650            mock_uds
3651                .expect_send()
3652                .times(1)
3653                .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
3654
3655            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3656                ecu_name.clone(),
3657                mock_uds,
3658                mock_file_manager,
3659            );
3660
3661            let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3662                handlers::WriteHandlerRequest {
3663                    service: "CalibrateSensor".to_string(),
3664                    headers: make_post_headers(),
3665                    body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3666                },
3667                &ecu_name,
3668                &state.uds,
3669                Arc::clone(&state.service_executions),
3670                Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3671                handlers::WriteHandlerOptions {
3672                    include_schema: false,
3673                    suppress_service: false,
3674                    base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3675                },
3676                &std::sync::atomic::AtomicBool::new(false),
3677            )
3678            .await;
3679
3680            assert_eq!(response.status(), StatusCode::OK);
3681        }
3682
3683        #[tokio::test]
3684        async fn test_post_operation_async_returns_202_and_tracks_execution() {
3685            let ecu_name = "TestECU".to_string();
3686            let mut mock_uds = MockUdsEcu::new();
3687            let mock_file_manager = MockFileManager::new();
3688
3689            mock_uds
3690                .expect_get_routine_subfunctions()
3691                .times(1)
3692                .returning(|_, _, _| {
3693                    Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3694                        has_stop: true,
3695                        has_request_results: true,
3696                    })
3697                });
3698            mock_uds
3699                .expect_send()
3700                .times(1)
3701                .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
3702
3703            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3704                ecu_name.clone(),
3705                mock_uds,
3706                mock_file_manager,
3707            );
3708
3709            let service_executions_ref = Arc::clone(&state.service_executions);
3710
3711            let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3712                handlers::WriteHandlerRequest {
3713                    service: "CalibrateSensor".to_string(),
3714                    headers: make_post_headers(),
3715                    body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3716                },
3717                &ecu_name,
3718                &state.uds,
3719                Arc::clone(&state.service_executions),
3720                Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3721                handlers::WriteHandlerOptions {
3722                    include_schema: false,
3723                    suppress_service: false,
3724                    base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3725                },
3726                &std::sync::atomic::AtomicBool::new(false),
3727            )
3728            .await;
3729
3730            assert_eq!(response.status(), StatusCode::ACCEPTED);
3731            assert_eq!(service_executions_ref.read().await.len(), 1);
3732        }
3733
3734        #[tokio::test]
3735        async fn test_post_operation_suppress_service_async_skips_send_returns_202_and_tracks() {
3736            let ecu_name = "TestECU".to_string();
3737            let mut mock_uds = MockUdsEcu::new();
3738            let mock_file_manager = MockFileManager::new();
3739
3740            mock_uds.expect_get_routine_subfunctions().times(0);
3741            // send must NOT be called when suppress_service=true
3742            mock_uds.expect_send().times(0);
3743
3744            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3745                ecu_name.clone(),
3746                mock_uds,
3747                mock_file_manager,
3748            );
3749
3750            let service_executions_ref = Arc::clone(&state.service_executions);
3751
3752            let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3753                handlers::WriteHandlerRequest {
3754                    service: "CalibrateSensor".to_string(),
3755                    headers: make_post_headers(),
3756                    body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3757                },
3758                &ecu_name,
3759                &state.uds,
3760                Arc::clone(&state.service_executions),
3761                Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3762                handlers::WriteHandlerOptions {
3763                    include_schema: false,
3764                    suppress_service: true,
3765                    base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3766                },
3767                &std::sync::atomic::AtomicBool::new(false),
3768            )
3769            .await;
3770
3771            assert_eq!(response.status(), StatusCode::ACCEPTED);
3772            // execution still tracked even though UDS was not called
3773            assert_eq!(service_executions_ref.read().await.len(), 1);
3774        }
3775
3776        #[tokio::test]
3777        async fn test_post_operation_async_into_json_error_surfaces_in_errors_not_500() {
3778            let ecu_name = "TestECU".to_string();
3779            let mut mock_uds = MockUdsEcu::new();
3780            let mock_file_manager = MockFileManager::new();
3781
3782            mock_uds
3783                .expect_get_routine_subfunctions()
3784                .times(1)
3785                .returning(|_, _, _| {
3786                    Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3787                        has_stop: true,
3788                        has_request_results: true,
3789                    })
3790                });
3791            // send returns a response whose into_json() fails
3792            mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3793                let mut resp = MockDiagServiceResponse::new();
3794                resp.expect_response_type()
3795                    .returning(|| DiagServiceResponseType::Positive);
3796                resp.expect_is_empty().returning(|| false);
3797                resp.expect_into_json().return_once(|| {
3798                    Err(DiagServiceError::BadPayload(
3799                        "simulated parse failure".to_string(),
3800                    ))
3801                });
3802                Ok(resp)
3803            });
3804
3805            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3806                ecu_name.clone(),
3807                mock_uds,
3808                mock_file_manager,
3809            );
3810
3811            let service_executions_ref = Arc::clone(&state.service_executions);
3812
3813            let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3814                handlers::WriteHandlerRequest {
3815                    service: "CalibrateSensor".to_string(),
3816                    headers: make_post_headers(),
3817                    body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3818                },
3819                &ecu_name,
3820                &state.uds,
3821                Arc::clone(&state.service_executions),
3822                Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3823                handlers::WriteHandlerOptions {
3824                    include_schema: false,
3825                    suppress_service: false,
3826                    base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3827                },
3828                &std::sync::atomic::AtomicBool::new(false),
3829            )
3830            .await;
3831
3832            // Must be 202, not 500 - spec Table 184 body has only id + status
3833            assert_eq!(response.status(), StatusCode::ACCEPTED);
3834            // Execution must still be tracked
3835            assert_eq!(service_executions_ref.read().await.len(), 1);
3836            // Body must contain id and status, no errors field
3837            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3838                .await
3839                .unwrap();
3840            let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3841            assert!(result.get("id").is_some(), "202 body must have id");
3842            assert!(result.get("status").is_some(), "202 body must have status");
3843            assert!(
3844                result.get("errors").is_none(),
3845                "202 body must not contain errors per spec Table 184"
3846            );
3847        }
3848
3849        #[tokio::test]
3850        async fn test_post_operation_async_non_object_json_surfaces_in_errors_not_500() {
3851            let ecu_name = "TestECU".to_string();
3852            let mut mock_uds = MockUdsEcu::new();
3853            let mock_file_manager = MockFileManager::new();
3854
3855            mock_uds
3856                .expect_get_routine_subfunctions()
3857                .times(1)
3858                .returning(|_, _, _| {
3859                    Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3860                        has_stop: true,
3861                        has_request_results: true,
3862                    })
3863                });
3864            // send returns a response whose into_json() gives a non-Object JSON value
3865            mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3866                let mut resp = MockDiagServiceResponse::new();
3867                resp.expect_response_type()
3868                    .returning(|| DiagServiceResponseType::Positive);
3869                resp.expect_is_empty().returning(|| false);
3870                resp.expect_into_json().return_once(|| {
3871                    Ok(DiagServiceJsonResponse {
3872                        data: serde_json::Value::String("unexpected_string".to_string()),
3873                        errors: vec![],
3874                    })
3875                });
3876                Ok(resp)
3877            });
3878
3879            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3880                ecu_name.clone(),
3881                mock_uds,
3882                mock_file_manager,
3883            );
3884
3885            let service_executions_ref = Arc::clone(&state.service_executions);
3886
3887            let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3888                handlers::WriteHandlerRequest {
3889                    service: "CalibrateSensor".to_string(),
3890                    headers: make_post_headers(),
3891                    body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3892                },
3893                &ecu_name,
3894                &state.uds,
3895                Arc::clone(&state.service_executions),
3896                Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3897                handlers::WriteHandlerOptions {
3898                    include_schema: false,
3899                    suppress_service: false,
3900                    base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3901                },
3902                &std::sync::atomic::AtomicBool::new(false),
3903            )
3904            .await;
3905
3906            // Must be 202, not 500 - spec Table 184 body has only id + status
3907            assert_eq!(response.status(), StatusCode::ACCEPTED);
3908            assert_eq!(service_executions_ref.read().await.len(), 1);
3909            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3910                .await
3911                .unwrap();
3912            let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3913            assert!(result.get("id").is_some(), "202 body must have id");
3914            assert!(result.get("status").is_some(), "202 body must have status");
3915            assert!(
3916                result.get("errors").is_none(),
3917                "202 body must not contain errors per spec Table 184"
3918            );
3919        }
3920
3921        #[tokio::test]
3922        async fn test_request_results_into_json_error_surfaces_in_errors_field() {
3923            let ecu_name = "TestECU".to_string();
3924            let mut mock_uds = MockUdsEcu::new();
3925            let mock_file_manager = MockFileManager::new();
3926
3927            // RequestResults returns a non-empty response whose into_json() fails
3928            mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3929                let mut resp = MockDiagServiceResponse::new();
3930                resp.expect_response_type()
3931                    .returning(|| DiagServiceResponseType::Positive);
3932                resp.expect_is_empty().returning(|| false);
3933                resp.expect_into_json().return_once(|| {
3934                    Err(DiagServiceError::BadPayload(
3935                        "simulated parse failure".to_string(),
3936                    ))
3937                });
3938                Ok(resp)
3939            });
3940
3941            let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3942                ecu_name,
3943                mock_uds,
3944                mock_file_manager,
3945            );
3946
3947            let exec_id = uuid::Uuid::new_v4();
3948            state
3949                .service_executions
3950                .write()
3951                .await
3952                .entry("CalibrateSensor".to_string())
3953                .or_default()
3954                .insert(
3955                    exec_id,
3956                    ServiceExecution {
3957                        parameters: serde_json::Map::new(),
3958                        status: ExecutionStatus::Running,
3959                        in_flight: false,
3960                        is_created: true,
3961                    },
3962                );
3963
3964            let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
3965                UseApi(
3966                    Secured(Box::new(TestSecurityPlugin)),
3967                    std::marker::PhantomData,
3968                ),
3969                Path(id_handlers::ServiceAndIdPathParam {
3970                    service: "CalibrateSensor".to_string(),
3971                    id: exec_id.to_string(),
3972                }),
3973                WithRejection(
3974                    Query(
3975                        sovd_interfaces::components::ecu::operations::OperationQuery {
3976                            include_schema: false,
3977                            suppress_service: false,
3978                        },
3979                    ),
3980                    std::marker::PhantomData,
3981                ),
3982                State(state),
3983            )
3984            .await;
3985
3986            // Must be 200, not 500
3987            assert_eq!(response.status(), StatusCode::OK);
3988            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3989                .await
3990                .unwrap();
3991            let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3992            // Spec Table 189: field is named `error` (singular key, array value)
3993            let errors = result.get("error").expect("missing error field");
3994            assert!(
3995                errors.as_array().is_some_and(|a| !a.is_empty()),
3996                "error should be non-empty when RequestResults into_json fails"
3997            );
3998        }
3999    }
4000}