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