1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
use crate::state::{SharedState, State};
use crate::{Alias, Device, DeviceType, Job, Tag, TestCase, TestSuite, Worker};

use boulder::Buildable;
use clone_replace::MutateGuard;
use django_query::mock::{nested_endpoint_matches, NestedEndpointParams};
use std::sync::Arc;

/// Pagination limits for constructing a [`LavaMock`] instance.
///
/// A running Lava instance allows the default pagination of endpoints
/// to be customised, and specifying default pagination can be
/// important for checking it is properly handled in clients that do
/// not usually specify their pagination directly.
///
/// Each member is an [`Option`], with `None` meaning that no
/// pagination is applied, otherwise `Some(n)` means that a maximum of
/// `n` results for objects of this type will be returned. The default
/// object provides no pagination for anything.
#[derive(Buildable, Clone, Default)]
pub struct PaginationLimits {
    aliases: Option<usize>,
    test_cases: Option<usize>,
    test_suites: Option<usize>,
    jobs: Option<usize>,
    device_types: Option<usize>,
    devices: Option<usize>,
    tags: Option<usize>,
    workers: Option<usize>,
}

impl PaginationLimits {
    /// Create a new [`PaginationLimits`]
    ///
    /// The created object will not ever trigger pagination by default
    /// for any endpoint.
    pub fn new() -> Self {
        Default::default()
    }
}

/// A mock server that provides access to a [`SharedState`].
///
/// This provides the following endpoints from the v0.2 Lava REST API:
/// - `/api/v0.2/aliases/`
/// - `/api/v0.2/devices/`
/// - `/api/v0.2/devicetypes/`
/// - `/api/v0.2/jobs/`
/// - `/api/v0.2/tags/`
/// - `/api/v0.2/workers/`
///
/// It also provides the following nested endpoints for jobs:
/// - `/api/v0.2/jobs/<id>/tests/`
/// - `/api/v0.2/jobs/<id>/suites/`
///
/// You can use [`uri`](LavaMock::uri) to find the initial portion
/// of the URL for your test instance.
///
/// The mock object does not support the Lava mutation endpoints, but
/// you can mutate the provided [`SharedState`] directly for testing.
/// There are two ways to do this:
/// - You can keep a clone of the [`SharedState`] you pass in and obtain
///   a [`MutateGuard`] with [`mutate`](SharedState::mutate).
/// - You can call [`state_mut`](LavaMock::state_mut) to get a [`MutateGuard`]
///   for the enclosed [`SharedState`] directly.
pub struct LavaMock {
    server: wiremock::MockServer,
    state: SharedState,
}

impl LavaMock {
    /// Create and start a new [`LavaMock`]
    ///
    /// Here `p` is the [`SharedState`] becomes the underlying data
    /// source for the mock, and `limits` are the default pagination
    /// limits as a [`PaginationLimits`] object, which are applied
    /// when the client does not give any.
    pub async fn new(p: SharedState, limits: PaginationLimits) -> LavaMock {
        let s = wiremock::MockServer::start().await;

        wiremock::Mock::given(wiremock::matchers::method("GET"))
            .and(wiremock::matchers::path("/api/v0.2/aliases/"))
            .respond_with(p.endpoint::<Alias<State>>(Some(&s.uri()), limits.aliases))
            .mount(&s)
            .await;

        wiremock::Mock::given(wiremock::matchers::method("GET"))
            .and(nested_endpoint_matches("/api/v0.2", "jobs", "tests"))
            .respond_with(p.nested_endpoint::<TestCase<State>>(
                NestedEndpointParams {
                    root: "/api/v0.2",
                    parent: "jobs",
                    child: "tests",
                    parent_query: "suite__job__id",
                    base_uri: Some(&s.uri()),
                },
                limits.test_cases,
            ))
            .mount(&s)
            .await;

        wiremock::Mock::given(wiremock::matchers::method("GET"))
            .and(wiremock::matchers::path_regex(
                r"^/api/v0.2/jobs/\d+/suites/$",
            ))
            .respond_with(p.nested_endpoint::<TestSuite<State>>(
                NestedEndpointParams {
                    root: "/api/v0.2",
                    parent: "jobs",
                    child: "suites",
                    parent_query: "suite__job__id",
                    base_uri: Some(&s.uri()),
                },
                limits.test_suites,
            ))
            .mount(&s)
            .await;

        wiremock::Mock::given(wiremock::matchers::method("GET"))
            .and(wiremock::matchers::path("/api/v0.2/jobs/"))
            .respond_with(p.endpoint::<Job<State>>(Some(&s.uri()), limits.jobs))
            .mount(&s)
            .await;

        wiremock::Mock::given(wiremock::matchers::method("GET"))
            .and(wiremock::matchers::path("/api/v0.2/devicetypes/"))
            .respond_with(p.endpoint::<DeviceType<State>>(Some(&s.uri()), limits.device_types))
            .mount(&s)
            .await;

        wiremock::Mock::given(wiremock::matchers::method("GET"))
            .and(wiremock::matchers::path("/api/v0.2/devices/"))
            .respond_with(p.endpoint::<Device<State>>(Some(&s.uri()), limits.devices))
            .mount(&s)
            .await;

        wiremock::Mock::given(wiremock::matchers::method("GET"))
            .and(wiremock::matchers::path("/api/v0.2/tags/"))
            .respond_with(p.endpoint::<Tag<State>>(Some(&s.uri()), limits.tags))
            .mount(&s)
            .await;

        wiremock::Mock::given(wiremock::matchers::method("GET"))
            .and(wiremock::matchers::path("/api/v0.2/workers/"))
            .respond_with(p.endpoint::<Worker<State>>(Some(&s.uri()), limits.workers))
            .mount(&s)
            .await;

        LavaMock {
            server: s,
            state: p,
        }
    }

    /// Create and start a default new [`LavaMock`].
    ///
    /// This mock will have a default [`SharedState`] and default
    /// [`PaginationLimits`]. This gives a mock object with an empty
    /// data store, and no default pagination (so if the client does
    /// not request pagination, all matching data will be returned).
    pub async fn start() -> Self {
        Self::new(Default::default(), Default::default()).await
    }

    /// Return the URI of the server.
    ///
    /// This object is based on a [`wiremock`] server, and as such it
    /// will usually be bound to an ephemeral port.
    pub fn uri(&self) -> String {
        self.server.uri()
    }

    /// Read a read-only view of the current state of the data store.
    ///
    /// Note that the data store is not currently prevented from
    /// evolving while this snapshot is held, because the underlying
    /// synchronisation mechanism is a
    /// [`CloneReplace`](clone_replace::CloneReplace).
    pub fn state(&self) -> Arc<State> {
        self.state.access()
    }

    /// Read a mutable view of the current state of the data store.
    ///
    /// Note that the data store is not currently prevented from
    /// evolving while this snapshot is held, because the underlying
    /// synchronisation mechanism is a
    /// [`CloneReplace`](clone_replace::CloneReplace). Other writers
    /// are not prevented from acting on the data store, and their
    /// changes will be lost when this guard is flushed. Note that
    /// changes from a [`MutateGuard`] only take effect when the guard
    /// is dropped.
    pub fn state_mut(&mut self) -> MutateGuard<State> {
        self.state.mutate()
    }
}

#[cfg(test)]
mod test {
    use super::*;

    use crate::{devicetypes::DeviceType, Device, Job, JobState};

    use anyhow::Result;
    use boulder::{
        BuildableWithPersianRug, BuilderWithPersianRug, GeneratableWithPersianRug,
        TryRepeatFromPersianRug,
    };
    use boulder::{GeneratorToGeneratorWithPersianRugWrapper, GeneratorWithPersianRugMutIterator};
    use chrono::Utc;
    use persian_rug::Proxy;
    use rand::{Rng, SeedableRng};
    use serde_json::Value;

    async fn make_request<T, U>(server_uri: T, endpoint: U) -> Result<Value>
    where
        T: AsRef<str>,
        U: AsRef<str>,
    {
        let url = format!("{}/api/v0.2/{}", server_uri.as_ref(), endpoint.as_ref());
        Ok(reqwest::get(&url).await?.json().await?)
    }

    #[tokio::test]
    async fn test() {
        let mut s = SharedState::new();

        let mut rng = rand::rngs::StdRng::seed_from_u64(0xdeadbeef);
        let device_types = ["device-type-1", "device-type-2"]
            .into_iter()
            .map(|name| {
                Proxy::<DeviceType<State>>::builder()
                    .name(name)
                    .build(s.mutate())
                    .0
            })
            .collect::<Vec<_>>();

        let types = device_types.clone();
        let mut devices = Proxy::<Device<State>>::generator().device_type(
            GeneratorToGeneratorWithPersianRugWrapper::new(move || {
                types[rng.gen_range(0..types.len())]
            }),
        );

        let _ = GeneratorWithPersianRugMutIterator::new(&mut devices, s.mutate())
            .take(90)
            .collect::<Vec<_>>();

        let mut rng = rand::rngs::StdRng::seed_from_u64(0xdeadbeef);

        let types = device_types.clone();
        let mut jobs = Proxy::<Job<State>>::generator()
            .actual_device(TryRepeatFromPersianRug::new())
            .state(GeneratorToGeneratorWithPersianRugWrapper::new(|| {
                JobState::Submitted
            }))
            .submit_time(GeneratorToGeneratorWithPersianRugWrapper::new(|| {
                Some(Utc::now())
            }))
            .requested_device_type(GeneratorToGeneratorWithPersianRugWrapper::new(move || {
                Some(types[rng.gen_range(0..types.len())])
            }));

        let _ = GeneratorWithPersianRugMutIterator::new(&mut jobs, s.mutate())
            .take(500)
            .collect::<Vec<_>>();

        let mock = LavaMock::new(s, Default::default()).await;

        let devices = make_request(mock.uri(), "devices/")
            .await
            .expect("failed to query devices");

        assert_eq!(devices["results"].as_array().unwrap().len(), 90);

        let jobs = make_request(mock.uri(), "jobs/")
            .await
            .expect("failed to query jobs");

        assert_eq!(jobs["results"].as_array().unwrap().len(), 500);
    }
}