lava_api_mock/
lava_mock.rs

1use crate::junit_endpoint;
2use crate::state::{SharedState, State};
3use crate::{Alias, Device, DeviceType, Job, Tag, TestCase, TestSuite, Worker};
4
5use boulder::Buildable;
6use clone_replace::MutateGuard;
7use django_query::mock::{nested_endpoint_matches, NestedEndpointParams};
8use std::sync::Arc;
9
10/// Pagination limits for constructing a [`LavaMock`] instance.
11///
12/// A running Lava instance allows the default pagination of endpoints
13/// to be customised, and specifying default pagination can be
14/// important for checking it is properly handled in clients that do
15/// not usually specify their pagination directly.
16///
17/// Each member is an [`Option`], with `None` meaning that no
18/// pagination is applied, otherwise `Some(n)` means that a maximum of
19/// `n` results for objects of this type will be returned. The default
20/// object provides no pagination for anything.
21#[derive(Buildable, Clone, Default)]
22pub struct PaginationLimits {
23    aliases: Option<usize>,
24    test_cases: Option<usize>,
25    test_suites: Option<usize>,
26    jobs: Option<usize>,
27    device_types: Option<usize>,
28    devices: Option<usize>,
29    tags: Option<usize>,
30    workers: Option<usize>,
31}
32
33impl PaginationLimits {
34    /// Create a new [`PaginationLimits`]
35    ///
36    /// The created object will not ever trigger pagination by default
37    /// for any endpoint.
38    pub fn new() -> Self {
39        Default::default()
40    }
41}
42
43/// A mock server that provides access to a [`SharedState`].
44///
45/// This provides the following endpoints from the v0.2 Lava REST API:
46/// - `/api/v0.2/aliases/`
47/// - `/api/v0.2/devices/`
48/// - `/api/v0.2/devicetypes/`
49/// - `/api/v0.2/jobs/`
50/// - `/api/v0.2/tags/`
51/// - `/api/v0.2/workers/`
52///
53/// It also provides the following nested endpoints for jobs:
54/// - `/api/v0.2/jobs/<id>/tests/`
55/// - `/api/v0.2/jobs/<id>/suites/`
56///
57/// You can use [`uri`](LavaMock::uri) to find the initial portion
58/// of the URL for your test instance.
59///
60/// The mock object does not support the Lava mutation endpoints, but
61/// you can mutate the provided [`SharedState`] directly for testing.
62/// There are two ways to do this:
63/// - You can keep a clone of the [`SharedState`] you pass in and obtain
64///   a [`MutateGuard`] with [`mutate`](SharedState::mutate).
65/// - You can call [`state_mut`](LavaMock::state_mut) to get a [`MutateGuard`]
66///   for the enclosed [`SharedState`] directly.
67pub struct LavaMock {
68    server: wiremock::MockServer,
69    state: SharedState,
70}
71
72impl LavaMock {
73    /// Create and start a new [`LavaMock`]
74    ///
75    /// Here `p` is the [`SharedState`] becomes the underlying data
76    /// source for the mock, and `limits` are the default pagination
77    /// limits as a [`PaginationLimits`] object, which are applied
78    /// when the client does not give any.
79    pub async fn new(p: SharedState, limits: PaginationLimits) -> LavaMock {
80        let s = wiremock::MockServer::start().await;
81
82        wiremock::Mock::given(wiremock::matchers::method("GET"))
83            .and(wiremock::matchers::path("/api/v0.2/aliases/"))
84            .respond_with(p.endpoint::<Alias<State>>(Some(&s.uri()), limits.aliases))
85            .mount(&s)
86            .await;
87
88        wiremock::Mock::given(wiremock::matchers::method("GET"))
89            .and(nested_endpoint_matches("/api/v0.2", "jobs", "tests"))
90            .respond_with(p.nested_endpoint::<TestCase<State>>(
91                NestedEndpointParams {
92                    root: "/api/v0.2",
93                    parent: "jobs",
94                    child: "tests",
95                    parent_query: "suite__job__id",
96                    base_uri: Some(&s.uri()),
97                },
98                limits.test_cases,
99            ))
100            .mount(&s)
101            .await;
102
103        wiremock::Mock::given(wiremock::matchers::method("GET"))
104            .and(nested_endpoint_matches("/api/v0.2", "jobs", "suites"))
105            .respond_with(p.nested_endpoint::<TestSuite<State>>(
106                NestedEndpointParams {
107                    root: "/api/v0.2",
108                    parent: "jobs",
109                    child: "suites",
110                    parent_query: "suite__job__id",
111                    base_uri: Some(&s.uri()),
112                },
113                limits.test_suites,
114            ))
115            .mount(&s)
116            .await;
117
118        wiremock::Mock::given(wiremock::matchers::method("GET"))
119            .and(nested_endpoint_matches("/api/v0.2", "jobs", "junit"))
120            .respond_with(junit_endpoint(p.clone()))
121            .mount(&s)
122            .await;
123
124        wiremock::Mock::given(wiremock::matchers::method("GET"))
125            .and(wiremock::matchers::path("/api/v0.2/jobs/"))
126            .respond_with(p.endpoint::<Job<State>>(Some(&s.uri()), limits.jobs))
127            .mount(&s)
128            .await;
129
130        wiremock::Mock::given(wiremock::matchers::method("GET"))
131            .and(wiremock::matchers::path("/api/v0.2/devicetypes/"))
132            .respond_with(p.endpoint::<DeviceType<State>>(Some(&s.uri()), limits.device_types))
133            .mount(&s)
134            .await;
135
136        wiremock::Mock::given(wiremock::matchers::method("GET"))
137            .and(wiremock::matchers::path("/api/v0.2/devices/"))
138            .respond_with(p.endpoint::<Device<State>>(Some(&s.uri()), limits.devices))
139            .mount(&s)
140            .await;
141
142        wiremock::Mock::given(wiremock::matchers::method("GET"))
143            .and(wiremock::matchers::path("/api/v0.2/tags/"))
144            .respond_with(p.endpoint::<Tag<State>>(Some(&s.uri()), limits.tags))
145            .mount(&s)
146            .await;
147
148        wiremock::Mock::given(wiremock::matchers::method("GET"))
149            .and(wiremock::matchers::path("/api/v0.2/workers/"))
150            .respond_with(p.endpoint::<Worker<State>>(Some(&s.uri()), limits.workers))
151            .mount(&s)
152            .await;
153
154        LavaMock {
155            server: s,
156            state: p,
157        }
158    }
159
160    /// Create and start a default new [`LavaMock`].
161    ///
162    /// This mock will have a default [`SharedState`] and default
163    /// [`PaginationLimits`]. This gives a mock object with an empty
164    /// data store, and no default pagination (so if the client does
165    /// not request pagination, all matching data will be returned).
166    pub async fn start() -> Self {
167        Self::new(Default::default(), Default::default()).await
168    }
169
170    /// Return the URI of the server.
171    ///
172    /// This object is based on a [`wiremock`] server, and as such it
173    /// will usually be bound to an ephemeral port.
174    pub fn uri(&self) -> String {
175        self.server.uri()
176    }
177
178    /// Read a read-only view of the current state of the data store.
179    ///
180    /// Note that the data store is not currently prevented from
181    /// evolving while this snapshot is held, because the underlying
182    /// synchronisation mechanism is a
183    /// [`CloneReplace`](clone_replace::CloneReplace).
184    pub fn state(&self) -> Arc<State> {
185        self.state.access()
186    }
187
188    /// Read a mutable view of the current state of the data store.
189    ///
190    /// Note that the data store is not currently prevented from
191    /// evolving while this snapshot is held, because the underlying
192    /// synchronisation mechanism is a
193    /// [`CloneReplace`](clone_replace::CloneReplace). Other writers
194    /// are not prevented from acting on the data store, and their
195    /// changes will be lost when this guard is flushed. Note that
196    /// changes from a [`MutateGuard`] only take effect when the guard
197    /// is dropped.
198    pub fn state_mut(&mut self) -> MutateGuard<State> {
199        self.state.mutate()
200    }
201}
202
203#[cfg(test)]
204mod test {
205    use super::*;
206
207    use crate::{devicetypes::DeviceType, Device, Job, JobState};
208
209    use anyhow::Result;
210    use boulder::{
211        BuildableWithPersianRug, BuilderWithPersianRug, GeneratableWithPersianRug,
212        TryRepeatFromPersianRug,
213    };
214    use boulder::{GeneratorToGeneratorWithPersianRugWrapper, GeneratorWithPersianRugMutIterator};
215    use chrono::Utc;
216    use persian_rug::Proxy;
217    use rand::{Rng, SeedableRng};
218    use serde_json::Value;
219
220    async fn make_request<T, U>(server_uri: T, endpoint: U) -> Result<Value>
221    where
222        T: AsRef<str>,
223        U: AsRef<str>,
224    {
225        let url = format!("{}/api/v0.2/{}", server_uri.as_ref(), endpoint.as_ref());
226        Ok(reqwest::get(&url).await?.json().await?)
227    }
228
229    #[tokio::test]
230    async fn test() {
231        let mut s = SharedState::new();
232
233        let mut rng = rand::rngs::StdRng::seed_from_u64(0xdeadbeef);
234        let device_types = ["device-type-1", "device-type-2"]
235            .into_iter()
236            .map(|name| {
237                Proxy::<DeviceType<State>>::builder()
238                    .name(name)
239                    .build(s.mutate())
240                    .0
241            })
242            .collect::<Vec<_>>();
243
244        let types = device_types.clone();
245        let mut devices = Proxy::<Device<State>>::generator().device_type(
246            GeneratorToGeneratorWithPersianRugWrapper::new(move || {
247                types[rng.gen_range(0..types.len())]
248            }),
249        );
250
251        let _ = GeneratorWithPersianRugMutIterator::new(&mut devices, s.mutate())
252            .take(90)
253            .collect::<Vec<_>>();
254
255        let mut rng = rand::rngs::StdRng::seed_from_u64(0xdeadbeef);
256
257        let types = device_types.clone();
258        let mut jobs = Proxy::<Job<State>>::generator()
259            .actual_device(TryRepeatFromPersianRug::new())
260            .state(GeneratorToGeneratorWithPersianRugWrapper::new(|| {
261                JobState::Submitted
262            }))
263            .submit_time(GeneratorToGeneratorWithPersianRugWrapper::new(|| {
264                Some(Utc::now())
265            }))
266            .requested_device_type(GeneratorToGeneratorWithPersianRugWrapper::new(move || {
267                Some(types[rng.gen_range(0..types.len())])
268            }));
269
270        let _ = GeneratorWithPersianRugMutIterator::new(&mut jobs, s.mutate())
271            .take(500)
272            .collect::<Vec<_>>();
273
274        let mock = LavaMock::new(s, Default::default()).await;
275
276        let devices = make_request(mock.uri(), "devices/")
277            .await
278            .expect("failed to query devices");
279
280        assert_eq!(devices["results"].as_array().unwrap().len(), 90);
281
282        let jobs = make_request(mock.uri(), "jobs/")
283            .await
284            .expect("failed to query jobs");
285
286        assert_eq!(jobs["results"].as_array().unwrap().len(), 500);
287    }
288}