@@ -875,6 +875,26 @@ impl TestServer {
875875 . expect ( "webapi submit deployment: invalid deployment id" )
876876 }
877877
878+ /// Submit a deployment via the Web API with an explicit (idempotency) deployment ID,
879+ /// returning the raw response so callers can assert on success or conflict.
880+ async fn webapi_submit_deployment_with_id (
881+ & self ,
882+ config_json : & str ,
883+ deployment_id : DeploymentId ,
884+ ) -> reqwest:: Response {
885+ self . client
886+ . post ( format ! ( "{}/v1/deployments" , self . base_url) )
887+ . header ( "Accept" , "application/json" )
888+ . json ( & json ! ( {
889+ "config_json" : config_json,
890+ "verify" : false ,
891+ "deployment_id" : deployment_id. to_string( ) ,
892+ } ) )
893+ . send ( )
894+ . await
895+ . expect ( "webapi submit deployment request failed" )
896+ }
897+
878898 /// Hot-redeploy to the given deployment via the Web API.
879899 async fn webapi_switch_hot_redeploy ( & self , deployment_id : DeploymentId ) {
880900 let resp = self
@@ -939,6 +959,7 @@ impl TestDeployClient {
939959 created_by : Some ( "test" . to_string ( ) ) ,
940960 verify : false ,
941961 description : None ,
962+ deployment_id : None ,
942963 } )
943964 . await
944965 . unwrap ( )
@@ -1657,6 +1678,79 @@ async fn list_components_grpc_filters_with_explicit_deployment_id() {
16571678 server. shutdown ( ) . await ;
16581679}
16591680
1681+ #[ tokio:: test]
1682+ async fn submit_deployment_is_idempotent_by_id_and_digest_webapi ( ) {
1683+ let server = TestServer :: start ( test_addr ! ( 40_104 ) ) . await ;
1684+
1685+ // Read the active deployment's canonical config so we can resubmit it verbatim.
1686+ let pool = SqlitePool :: new ( & server. sqlite_file , SqliteConfig :: default ( ) )
1687+ . await
1688+ . unwrap ( ) ;
1689+ let conn = pool. external_api_conn ( ) . await . unwrap ( ) ;
1690+ let active = conn. get_active_deployment ( ) . await . unwrap ( ) . unwrap ( ) ;
1691+ pool. close ( ) . await ;
1692+ let canonical: DeploymentCanonical = serde_json:: from_str ( & active. config_json ) . unwrap ( ) ;
1693+ let config_json = crate :: config:: toml:: compute_config_json ( & canonical) ;
1694+
1695+ let deployment_id = DeploymentId :: generate ( ) ;
1696+
1697+ // First submission under the explicit ID creates the deployment.
1698+ let resp1 = server
1699+ . webapi_submit_deployment_with_id ( & config_json, deployment_id)
1700+ . await ;
1701+ assert ! (
1702+ resp1. status( ) . is_success( ) ,
1703+ "first submit failed: {}" ,
1704+ resp1. status( )
1705+ ) ;
1706+ let body1: Value = resp1. json ( ) . await . unwrap ( ) ;
1707+ let returned1: DeploymentId = body1[ "ok" ] . as_str ( ) . unwrap ( ) . parse ( ) . unwrap ( ) ;
1708+ assert_eq ! ( deployment_id, returned1, "explicit ID must be honored" ) ;
1709+
1710+ // Resubmitting the identical config under the same ID is an idempotent no-op.
1711+ let resp2 = server
1712+ . webapi_submit_deployment_with_id ( & config_json, deployment_id)
1713+ . await ;
1714+ assert ! (
1715+ resp2. status( ) . is_success( ) ,
1716+ "idempotent resubmit failed: {}" ,
1717+ resp2. status( )
1718+ ) ;
1719+ let body2: Value = resp2. json ( ) . await . unwrap ( ) ;
1720+ let returned2: DeploymentId = body2[ "ok" ] . as_str ( ) . unwrap ( ) . parse ( ) . unwrap ( ) ;
1721+ assert_eq ! (
1722+ deployment_id, returned2,
1723+ "no-op resubmit must return same ID"
1724+ ) ;
1725+
1726+ // Submitting a different config under the same ID is rejected as a digest conflict.
1727+ let mut mutated: DeploymentCanonical = serde_json:: from_str ( & active. config_json ) . unwrap ( ) ;
1728+ mutated
1729+ . activities_stub
1730+ . push ( ActivityStubComponentConfigCanonical :: Inline (
1731+ ActivityStubExtInlineConfigCanonical {
1732+ name : ConfigName :: new ( concepts:: StrVariant :: from ( "idempotency_conflict_stub" ) )
1733+ . unwrap ( ) ,
1734+ ffqn : "testing:integration/stubs.idempotency-conflict"
1735+ . parse ( )
1736+ . unwrap ( ) ,
1737+ params : Some ( vec ! [ ] ) ,
1738+ return_type : Some ( "result<string, string>" . to_string ( ) ) ,
1739+ } ,
1740+ ) ) ;
1741+ let mutated_json = crate :: config:: toml:: compute_config_json ( & mutated) ;
1742+ let resp3 = server
1743+ . webapi_submit_deployment_with_id ( & mutated_json, deployment_id)
1744+ . await ;
1745+ assert_eq ! (
1746+ resp3. status( ) ,
1747+ reqwest:: StatusCode :: BAD_REQUEST ,
1748+ "different config under same ID must be rejected as a digest conflict"
1749+ ) ;
1750+
1751+ server. shutdown ( ) . await ;
1752+ }
1753+
16601754// ---- Activity: submit + result ----
16611755
16621756#[ tokio:: test]
0 commit comments