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