1use super::*;
3use poolster_core::engine::{Handle, Meta, Plugin, PluginContext, Requirement};
4use serde_json::{Value, json};
5pub struct OperationTests {
6 meta: Meta,
7 sdk: Option<Handle<NativeSdk>>,
8 max_operations: usize,
9}
10pub fn operation_tests() -> OperationTests {
11 OperationTests {
12 meta: Meta::new(),
13 sdk: None,
14 max_operations: 128,
15 }
16}
17impl OperationTests {
18 pub fn using_sdk(mut self, handle: Handle<NativeSdk>) -> Self {
19 self.sdk = Some(handle);
20 self
21 }
22 pub fn sdk_from(self, sdk: &Sdk) -> Self {
23 self.using_sdk(sdk.contract())
24 }
25 pub fn max_operations(mut self, limit: usize) -> Self {
26 self.max_operations = limit;
27 self
28 }
29}
30impl Plugin<Java> for OperationTests {
31 fn kind(&self) -> &'static str {
32 "java-operation-tests"
33 }
34 fn meta(&self) -> &Meta {
35 &self.meta
36 }
37 fn requires(&self) -> Vec<Requirement> {
38 vec![Requirement::on(self.sdk)]
39 }
40 fn generate(&self, cx: &mut PluginContext<'_, Java>) -> Result<()> {
41 anyhow::ensure!(
42 self.max_operations > 0 && self.max_operations <= 128,
43 "operation test bound must be 1..128"
44 );
45 let sdk = cx.inputs.get::<NativeSdk>()?;
46 let api = prepare_api(&operation_samples::clean_api(cx.api));
47 let mut code = String::new();
48 let mut cases = vec![];
49 let mut unsupported = vec![];
50 for (index, operation) in api.operations.iter().enumerate() {
51 if index >= self.max_operations {
52 unsupported.push(
53 json!({"operation":operation.id,"reason":"operation count bound exceeded"}),
54 );
55 continue;
56 }
57 match operation_samples::case(&api, operation) {
58 Ok(mut fixture) => {
59 fixture["encoded_path"] = json!(operation_samples::encoded_path(&fixture));
60 let fixture_json = serde_json::to_string(&fixture)?;
61 let mut input = serde_json::Map::new();
62 for parameter in fixture["parameters"].as_array().unwrap() {
63 input.insert(
64 parameter_name(
65 operation
66 .parameters
67 .iter()
68 .find(|candidate| {
69 candidate.name == parameter["name"].as_str().unwrap()
70 && candidate.location
71 == parameter["location"].as_str().unwrap()
72 })
73 .unwrap(),
74 ),
75 parameter["value"].clone(),
76 );
77 }
78 if fixture["has_body"] == true {
79 input.insert("body".into(), fixture["body"].clone());
80 }
81 let arg = if input.is_empty() {
82 String::new()
83 } else {
84 format!(
85 "MAPPER.readValue({},Client.{}Request.class)",
86 serde_json::to_string(&Value::Object(input).to_string())?,
87 type_name(&operation.id)
88 )
89 };
90 code += &format!(
91 " {{ var driver=new Driver(MAPPER.readTree({})); var client=new Client(new ClientConfig(\"https://poolster-test.invalid\",null,\"Authorization\",\"Bearer\",Map.of(),driver,Duration.ofSeconds(1),null,null)); var result=client.{}({arg}); assertResult(driver,result); }}\n",
92 serde_json::to_string(&fixture_json)?,
93 method_name(&operation.id)
94 );
95 cases.push(fixture);
96 }
97 Err(error) => {
98 unsupported.push(json!({"operation":operation.id,"reason":error.to_string()}))
99 }
100 }
101 }
102 let source = include_str!("../tests/fixtures/operation_driver.java")
103 .replace("__PACKAGE__", &sdk.namespace)
104 .replace("__CASES__", &code);
105 cx.files.emit(GeneratedFile::new(
106 format!(
107 "src/test/java/{}/PoolsterOperationTests.java",
108 sdk.namespace.replace('.', "/")
109 ),
110 source,
111 )?)?;
112 cx.files.emit(GeneratedFile::new(
113 "operation-test-report.json",
114 serde_json::to_string_pretty(
115 &json!({"version":1,"supported":cases,"unsupported":unsupported}),
116 )?,
117 )?)?;
118 cx.files.emit(GeneratedFile::new("OPERATION_TESTS.md",format!("# Generated operation smoke tests\n\nAll HTTP is fake and in memory. Run:\n\n~~~sh\nmvn -q test-compile org.codehaus.mojo:exec-maven-plugin:3.5.0:java -Dexec.mainClass={}.PoolsterOperationTests -Dexec.classpathScope=test\n~~~\n\noperation-test-report.json records supported cases and exclusions. Bounded structural samples strip source examples, defaults and annotations. These tests verify public operation calls, wire controls, bodies and decoded models; dedicated fixtures are still needed for constraints, recursive/compositional schemas, auth, pagination, retries, and streaming.\n",sdk.namespace))?)?;
119 Ok(())
120 }
121}
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use poolster_core::engine::Packages;
126 use poolster_core::{
127 AdditionalProperties, Field, HttpMethod, OperationMediaType, OperationParameter,
128 OperationRequestBody, OperationResponse,
129 };
130 fn api() -> Api {
131 let mut optional = SchemaValue::new(SchemaKind::String);
132 optional.nullable = true;
133 let mut body = SchemaValue::new(SchemaKind::Object {
134 fields: vec![
135 Field {
136 name: "id".into(),
137 value: SchemaValue::new(SchemaKind::String),
138 required: true,
139 annotations: Default::default(),
140 },
141 Field {
142 name: "enabled".into(),
143 value: SchemaValue::new(SchemaKind::Boolean),
144 required: true,
145 annotations: Default::default(),
146 },
147 Field {
148 name: "count".into(),
149 value: SchemaValue::new(SchemaKind::Integer),
150 required: true,
151 annotations: Default::default(),
152 },
153 Field {
154 name: "note".into(),
155 value: optional,
156 required: true,
157 annotations: Default::default(),
158 },
159 ],
160 additional_properties: AdditionalProperties::Forbidden,
161 });
162 body.default = Some(json!("DO_NOT_COPY_SOURCE_SECRET"));
163 body.extensions
164 .insert("x-secret".into(), json!("DO_NOT_COPY_SOURCE_SECRET"));
165 body.constraints
166 .insert("examples".into(), json!(["DO_NOT_COPY_SOURCE_SECRET"]));
167 let parameter =
168 |name: &str, location: &str, kind: SchemaKind, required: bool| OperationParameter {
169 name: name.into(),
170 location: location.into(),
171 required,
172 schema: Some(SchemaValue::new(kind)),
173 description: None,
174 annotations: Default::default(),
175 };
176 let echo = Operation {
177 id: "echoContact".into(),
178 method: HttpMethod::Post,
179 path: "/contacts/{contact_id}".into(),
180 parameters: vec![
181 parameter("contact_id", "path", SchemaKind::String, true),
182 parameter("enabled", "query", SchemaKind::Boolean, false),
183 parameter("count", "query", SchemaKind::Integer, false),
184 parameter("x-label", "header", SchemaKind::String, false),
185 ],
186 request_body: Some(OperationRequestBody::json(
187 SchemaValue::reference("#/components/schemas/Contact"),
188 true,
189 )),
190 responses: vec![OperationResponse::json(
191 "200",
192 SchemaValue::reference("#/components/schemas/Contact"),
193 )],
194 ..Default::default()
195 };
196 let mut unsupported = echo.clone();
197 unsupported.id = "streamContacts".into();
198 unsupported.request_body = None;
199 unsupported.responses[0].media_types = vec![OperationMediaType {
200 content_type: "text/event-stream".into(),
201 schema: None,
202 }];
203 let get = Operation {
204 id: "getContact".into(),
205 method: HttpMethod::Get,
206 path: "/contacts".into(),
207 responses: echo.responses.clone(),
208 ..Default::default()
209 };
210 Api {
211 name: "Test".into(),
212 version: "1.0.0".into(),
213 schemas: vec![Schema::new("Contact", body)],
214 operations: vec![echo, get, unsupported],
215 ..Default::default()
216 }
217 }
218 fn generate() -> GeneratedTree {
219 let sdk = crate::sdk();
220 Packages::new()
221 .package(
222 crate::package("sdk")
223 .name("io.poolster.operationtest")
224 .with(operation_tests().sdk_from(&sdk))
225 .with(sdk),
226 )
227 .generate(&api(), None)
228 .unwrap()
229 }
230 #[test]
231 fn generated_smoke_tests_use_sdk_contract_and_sanitized_bounded_samples() {
232 let tree = generate();
233 let report: Value =
234 serde_json::from_str(tree.get("sdk/operation-test-report.json").unwrap()).unwrap();
235 assert_eq!(report["supported"].as_array().unwrap().len(), 2);
236 assert_eq!(report["unsupported"][0]["operation"], "streamContacts");
237 let report_text = tree.get("sdk/operation-test-report.json").unwrap();
238 assert!(!report_text.contains("DO_NOT_COPY_SOURCE_SECRET"));
239 assert!(report_text.contains("encoded_path"));
240 let source = tree
241 .get("sdk/src/test/java/io/poolster/operationtest/PoolsterOperationTests.java")
242 .unwrap();
243 assert!(source.contains("client.echoContact("));
244 assert!(source.contains("request.bodyPublisher()"));
245 assert!(source.contains("one native request required"));
246 let error = Packages::new()
247 .package(crate::package("sdk").with(operation_tests()))
248 .generate(&api(), None)
249 .unwrap_err();
250 assert!(format!("{error:#}").contains("native-sdk"));
251 assert!(
252 Packages::new()
253 .package(
254 crate::package("sdk")
255 .with(crate::sdk())
256 .with(operation_tests().max_operations(0))
257 )
258 .generate(&api(), None)
259 .is_err()
260 );
261 let bounded = Packages::new()
262 .package(
263 crate::package("sdk")
264 .with(crate::sdk())
265 .with(operation_tests().max_operations(1)),
266 )
267 .generate(&api(), None)
268 .unwrap();
269 let report: Value =
270 serde_json::from_str(bounded.get("sdk/operation-test-report.json").unwrap()).unwrap();
271 assert_eq!(report["supported"].as_array().unwrap().len(), 1);
272 assert!(
273 report["unsupported"]
274 .as_array()
275 .unwrap()
276 .iter()
277 .any(|case| case["reason"] == "operation count bound exceeded")
278 );
279 }
280 #[test]
281 #[ignore = "requires Maven and JDK17; executes generated public operation calls through in-memory native HTTP driver"]
282 fn native_generated_operation_tests_execute() {
283 let tree = generate();
284 let dir = tempfile::tempdir().unwrap();
285 tree.write_to(dir.path()).unwrap();
286 let result = std::process::Command::new("mvn")
287 .args([
288 "-q",
289 "test-compile",
290 "org.codehaus.mojo:exec-maven-plugin:3.5.0:java",
291 "-Dexec.mainClass=io.poolster.operationtest.PoolsterOperationTests",
292 "-Dexec.classpathScope=test",
293 ])
294 .current_dir(dir.path().join("sdk"))
295 .output()
296 .unwrap();
297 assert!(
298 result.status.success(),
299 "{}{}",
300 String::from_utf8_lossy(&result.stdout),
301 String::from_utf8_lossy(&result.stderr)
302 );
303 }
304}