bitwarden_test/play/
play.rs

1//! Main Play struct with builder pattern and closure-based cleanup
2
3use std::{future::Future, panic::AssertUnwindSafe, sync::Arc};
4
5use futures::FutureExt;
6use uuid::Uuid;
7
8use super::{
9    CreateSceneRequest, CreateSceneResponse, PlayConfig, PlayHttpClient, PlayResult, Query,
10    QueryRequest, Scene, SceneTemplate,
11};
12
13/// Builder for Play instances with closure-based execution
14///
15/// Use [`Play::builder()`] to create a builder, then chain configuration methods
16/// and call [`run()`](PlayBuilder::run) to execute your test with automatic cleanup.
17///
18/// # Example
19///
20/// ```ignore
21/// use bitwarden_test::play::{Play, SingleUserArgs, SingleUserScene};
22///
23/// #[tokio::test]
24/// async fn test_user_login() {
25///     Play::builder()
26///         .run(|play| async move {
27///             let args = SingleUserArgs {
28///                 email: "[email protected]".to_string(),
29///                 ..Default::default()
30///             };
31///             let scene = play.scene::<SingleUserScene>(&args).await.unwrap();
32///             // Cleanup happens automatically when run() completes
33///         })
34///         .await;
35/// }
36/// ```
37pub struct PlayBuilder {
38    config: Option<PlayConfig>,
39}
40
41impl PlayBuilder {
42    /// Create a new builder with default configuration
43    fn new() -> Self {
44        Self { config: None }
45    }
46
47    /// Set custom configuration for the Play instance
48    ///
49    /// If not called, configuration is loaded from environment variables.
50    pub fn config(mut self, config: PlayConfig) -> Self {
51        self.config = Some(config);
52        self
53    }
54
55    /// Run a test with automatic cleanup
56    ///
57    /// The closure receives a [`Play`] instance and can perform any test operations.
58    /// Cleanup is guaranteed to run after the closure completes, regardless of
59    /// whether it returns normally or panics.
60    ///
61    /// # Panics
62    ///
63    /// If the closure panics, cleanup still runs before the panic is propagated.
64    pub async fn run<F, Fut, T>(self, f: F) -> T
65    where
66        F: FnOnce(Play) -> Fut,
67        Fut: Future<Output = T>,
68    {
69        let config = self.config.unwrap_or_else(PlayConfig::from_env);
70        let play = Play::new_internal(config);
71
72        // Execute the closure and catch any panics
73        let result = AssertUnwindSafe(f(play.clone())).catch_unwind().await;
74
75        // Always cleanup, regardless of success/failure
76        if let Err(e) = play.clean().await {
77            tracing::warn!("Play cleanup failed: {:?}", e);
78        }
79
80        // Propagate panic or return result
81        match result {
82            Ok(value) => value,
83            Err(panic) => std::panic::resume_unwind(panic),
84        }
85    }
86}
87
88/// The Play test framework for E2E testing
89///
90/// Provides methods for creating scenes, executing queries, and managing
91/// test data with automatic cleanup.
92///
93/// # Example
94///
95/// ```ignore
96/// use bitwarden_test::play::{Play, SingleUserArgs, SingleUserScene};
97///
98/// #[tokio::test]
99/// async fn test_user_login() {
100///     Play::builder()
101///         .run(|play| async move {
102///             let args = SingleUserArgs {
103///                 email: "[email protected]".to_string(),
104///                 verified: true,
105///                 ..Default::default()
106///             };
107///             let scene = play.scene::<SingleUserScene>(&args).await.unwrap();
108///
109///             // Use scene.get_mangled() to look up mangled values
110///             let client_id = scene.get_mangled("client_id");
111///
112///             // Cleanup is automatic when run() completes
113///         })
114///         .await;
115/// }
116/// ```
117#[derive(Clone)]
118pub struct Play {
119    client: Arc<PlayHttpClient>,
120}
121
122impl Play {
123    /// Create a new Play builder
124    ///
125    /// Use the builder to configure the Play instance and run tests with
126    /// automatic cleanup.
127    ///
128    /// # Example
129    ///
130    /// ```ignore
131    /// Play::builder()
132    ///     .run(|play| async move {
133    ///         // test code
134    ///     })
135    ///     .await;
136    /// ```
137    pub fn builder() -> PlayBuilder {
138        PlayBuilder::new()
139    }
140
141    /// Internal constructor for creating Play instances
142    fn new_internal(config: PlayConfig) -> Self {
143        let play_id = Uuid::new_v4().to_string();
144        let client = Arc::new(PlayHttpClient::new(play_id, config));
145        Play { client }
146    }
147
148    /// Create a new scene from template arguments
149    ///
150    /// The scene data will be cleaned up when the enclosing `run()` completes.
151    pub async fn scene<T>(&self, arguments: &T::Arguments) -> PlayResult<Scene<T>>
152    where
153        T: SceneTemplate,
154    {
155        let request = CreateSceneRequest {
156            template: T::template_name(),
157            arguments,
158        };
159
160        let response: CreateSceneResponse<T::Result> =
161            self.client.post_seeder("/seed/", &request).await?;
162
163        Ok(Scene::new(response.result, response.mangle_map))
164    }
165
166    /// Execute a query
167    pub async fn query<Q>(&self, arguments: &Q::Args) -> PlayResult<Q>
168    where
169        Q: Query,
170    {
171        let request = QueryRequest {
172            template: Q::template_name(),
173            arguments,
174        };
175
176        let result: Q::Result = self.client.post_seeder("/seed/query", &request).await?;
177
178        Ok(Q::from_result(result))
179    }
180
181    /// Clean all test data for this play_id
182    ///
183    /// This is called automatically by [`PlayBuilder::run()`], but can be called
184    /// manually if needed.
185    pub async fn clean(&self) -> PlayResult<()> {
186        self.client
187            .delete_seeder(&format!("/seed/{}", self.client.play_id()))
188            .await
189    }
190
191    /// Get the play_id for this instance
192    pub fn play_id(&self) -> &str {
193        self.client.play_id()
194    }
195
196    /// Get the configuration
197    pub fn config(&self) -> &PlayConfig {
198        self.client.config()
199    }
200}
201
202impl Default for PlayBuilder {
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use serde::{Deserialize, Serialize};
211    use wiremock::{
212        Mock, MockServer, ResponseTemplate,
213        matchers::{method, path},
214    };
215
216    use super::*;
217
218    /// Creates a Play instance connected to a mock server with DELETE pre-configured.
219    async fn play_with_mock_server() -> (Play, MockServer) {
220        let server = MockServer::start().await;
221        Mock::given(method("DELETE"))
222            .respond_with(ResponseTemplate::new(200))
223            .mount(&server)
224            .await;
225        let config = PlayConfig::new(
226            "https://api.example.com",
227            "https://identity.example.com",
228            server.uri(),
229        );
230        (Play::new_internal(config), server)
231    }
232
233    #[tokio::test]
234    async fn test_play_instances() {
235        let server = MockServer::start().await;
236        Mock::given(method("DELETE"))
237            .respond_with(ResponseTemplate::new(200))
238            .mount(&server)
239            .await;
240
241        let config = PlayConfig::new(
242            "https://api.example.com",
243            "https://identity.example.com",
244            server.uri(),
245        );
246
247        Play::builder()
248            .config(config.clone())
249            .run(|play1| async move {
250                // Check first instance has valid UUID
251                assert!(Uuid::parse_str(play1.play_id()).is_ok());
252                assert_eq!(play1.config().seeder_url, server.uri());
253            })
254            .await;
255    }
256
257    #[tokio::test]
258    async fn test_unique_play_ids() {
259        let server = MockServer::start().await;
260        Mock::given(method("DELETE"))
261            .respond_with(ResponseTemplate::new(200))
262            .mount(&server)
263            .await;
264
265        let config = PlayConfig::new(
266            "https://api.example.com",
267            "https://identity.example.com",
268            server.uri(),
269        );
270
271        let play1 = Play::new_internal(config.clone());
272        let play2 = Play::new_internal(config);
273
274        // Each instance has unique UUID
275        assert!(Uuid::parse_str(play1.play_id()).is_ok());
276        assert!(Uuid::parse_str(play2.play_id()).is_ok());
277        assert_ne!(play1.play_id(), play2.play_id());
278    }
279
280    // Mock types for testing scene/query functionality
281    struct MockScene;
282
283    #[derive(Clone, Serialize)]
284    struct MockSceneArgs {
285        name: String,
286    }
287
288    #[derive(Deserialize)]
289    struct MockSceneResult {
290        data: String,
291    }
292
293    impl SceneTemplate for MockScene {
294        type Arguments = MockSceneArgs;
295        type Result = MockSceneResult;
296
297        fn template_name() -> &'static str {
298            "MockScene"
299        }
300    }
301
302    #[derive(Debug, Clone)]
303    struct MockQuery {
304        args: MockQueryArgs,
305        value: i32,
306    }
307
308    #[derive(Debug, Clone, Serialize)]
309    struct MockQueryArgs {
310        id: String,
311    }
312
313    impl Query for MockQuery {
314        type Args = MockQueryArgs;
315        type Result = MockQueryResult;
316
317        fn template_name() -> &'static str {
318            "MockQuery"
319        }
320
321        fn args(&self) -> &Self::Args {
322            &self.args
323        }
324
325        fn from_result(result: Self::Result) -> Self {
326            Self {
327                args: MockQueryArgs { id: String::new() },
328                value: result.value,
329            }
330        }
331    }
332
333    #[derive(Deserialize)]
334    struct MockQueryResult {
335        value: i32,
336    }
337
338    #[tokio::test]
339    async fn test_scene_and_query() {
340        let (play, server) = play_with_mock_server().await;
341
342        // Test scene creation
343        Mock::given(method("POST"))
344            .and(path("/seed/"))
345            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
346                "result": { "data": "test-data" },
347                "mangleMap": { "[email protected]": "[email protected]" }
348            })))
349            .mount(&server)
350            .await;
351
352        let scene = play
353            .scene::<MockScene>(&MockSceneArgs {
354                name: "test".into(),
355            })
356            .await
357            .expect("scene creation should succeed");
358        assert_eq!(scene.result().data, "test-data");
359        assert_eq!(
360            scene.get_mangled("[email protected]"),
361            "[email protected]"
362        );
363
364        // Test query execution
365        Mock::given(method("POST"))
366            .and(path("/seed/query"))
367            .respond_with(
368                ResponseTemplate::new(200).set_body_json(serde_json::json!({ "value": 42 })),
369            )
370            .mount(&server)
371            .await;
372
373        let result = play
374            .query::<MockQuery>(&MockQueryArgs { id: "test".into() })
375            .await
376            .expect("query should succeed");
377        assert_eq!(result.value, 42);
378    }
379
380    #[tokio::test]
381    async fn test_server_error_handling() {
382        let (play, server) = play_with_mock_server().await;
383
384        Mock::given(method("POST"))
385            .and(path("/seed/"))
386            .respond_with(ResponseTemplate::new(500))
387            .mount(&server)
388            .await;
389
390        let result = play
391            .scene::<MockScene>(&MockSceneArgs {
392                name: "test".into(),
393            })
394            .await;
395
396        assert!(matches!(
397            result,
398            Err(super::super::PlayError::ServerError { status: 500, .. })
399        ));
400    }
401
402    #[tokio::test]
403    async fn test_builder_runs_cleanup() {
404        let server = MockServer::start().await;
405        Mock::given(method("DELETE"))
406            .respond_with(ResponseTemplate::new(200))
407            .expect(1)
408            .mount(&server)
409            .await;
410
411        let config = PlayConfig::new(
412            "https://api.example.com",
413            "https://identity.example.com",
414            server.uri(),
415        );
416
417        Play::builder()
418            .config(config)
419            .run(|_play| async move {
420                // Test completes normally
421            })
422            .await;
423
424        // The mock server will verify DELETE was called exactly once
425    }
426
427    #[tokio::test]
428    async fn test_clean() {
429        let server = MockServer::start().await;
430        Mock::given(method("DELETE"))
431            .respond_with(ResponseTemplate::new(200))
432            .expect(1)
433            .mount(&server)
434            .await;
435
436        let config = PlayConfig::new(
437            "https://api.example.com",
438            "https://identity.example.com",
439            server.uri(),
440        );
441        let play = Play::new_internal(config);
442
443        assert!(play.clean().await.is_ok());
444    }
445}