Skip to main content

poolster_plugin_java/
operation_tests.rs

1//! Independent generated buffered-operation smoke tests; never contact an API.
2use 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}