Skip to content

Commit 1785f8e

Browse files
committed
Begin working on a revamped test harness
This is yet another attempt to improve our test harness. This is intended to be a replacement for `TestApp` which currently lives in `tests/util.rs` This new structure tries to address several pain points I've had using the existing harness: - Abstracting into smaller pieces Most of the functions in the current harness give no opportunity to modify the request, require a type to parse the response into, and assume the request is a 2xx or 3xx response. This has made testing our edge cases difficult, and they're less well tested as a result. I've attempted to make it easier to do: "just like this other thing but with an additional header set", or "I don't care about the response body" What was previously `anon.get::<T>("...")` will now be 3 separate pieces: - `app.get("...")` returns a request object that can be modified if needed - `request.send()` returns a `Result`, where a 4xx or 5xx response is considered an error - `response.json<T>()` will parse the body into the given type The API is roughly modeled after `reqwest`, which I've enjoyed using in our app. - More explicit handling of auth The current API for auth has felt really funky to me. Having an object called `User` constructing HTTP requests felt backwards, and it also meant we had to decide up front whether we were going to send some requests signed in or not, and also how we were authenticating. It also didn't have a good way to send requests as more than one user. The new API moves auth to be a part of request construction. So `user.get("...")` becomes `app.get("...").as_user(&user)` Finally, I've structured this new API around returning `Result` everywhere, so that we can keep assertions closer to the tests where they matter (giving line numbers in the test, not the util file). This is a bit tricky, and I'll probably be tweaking as I move tests over, since results bubbled all the way up with `?` will completely lose the backtrace. Right now, I'm figuring that things we really just don't expect to fail ever get bubbled up with `?`, while the things we're actually testing will remain as assertions. The tests I've moved over don't have a ton to abstract, but I'd also like to keep helpers like `publish` off of the `App` struct, and instead keep them in the test file where they're relevant. I've started by just moving `tests/read_only_mode` over, since it's a relatively small file, and also one where the old API was more painful for me to use. I'll be porting other files over in the next few days.
1 parent 59a4ca9 commit 1785f8e

File tree

8 files changed

+248
-30
lines changed

8 files changed

+248
-30
lines changed

src/tests/all.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ macro_rules! bad_resp {
6161
}};
6262
}
6363

64+
mod helpers;
65+
mod prelude;
66+
6467
mod badge;
6568
mod builders;
6669
mod categories;

src/tests/builders.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ impl<'a> CrateBuilder<'a> {
229229
self
230230
}
231231

232-
fn build(mut self, connection: &PgConnection) -> CargoResult<Crate> {
232+
pub fn build(mut self, connection: &PgConnection) -> CargoResult<Crate> {
233233
use diesel::{insert_into, select, update};
234234

235235
let mut krate = self

src/tests/helpers.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod app;
2+
pub mod request;
3+
pub mod response;

src/tests/helpers/app.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
use super::request::RequestBuilder;
2+
use cargo_registry::models::{ApiToken, NewUser, User};
3+
use cargo_registry::util::CargoResult;
4+
use conduit::Method;
5+
use conduit_middleware::MiddlewareBuilder;
6+
use diesel::prelude::*;
7+
use std::sync::Arc;
8+
9+
pub struct App {
10+
app: Arc<cargo_registry::App>,
11+
middleware: MiddlewareBuilder,
12+
}
13+
14+
impl App {
15+
pub fn new() -> Self {
16+
let (app, middleware) = crate::simple_app(None);
17+
Self { app, middleware }
18+
}
19+
20+
/// Obtain the database connection and pass it to the closure
21+
///
22+
/// Our tests share a database connection with the app server, so it's
23+
/// important that the conenction is dropped before requests are made to
24+
/// ensure it's available for the server to use. The connection will be
25+
/// returned to the server after the given function returns.
26+
pub fn db<T, F>(&self, f: F) -> CargoResult<T>
27+
where
28+
F: FnOnce(&PgConnection) -> CargoResult<T>,
29+
{
30+
let conn = self.app.diesel_database.get()?;
31+
f(&conn)
32+
}
33+
34+
/// Create a new user in the database with the given id
35+
pub fn create_user(&self, username: &str) -> CargoResult<User> {
36+
self.db(|conn| {
37+
let new_user = NewUser {
38+
email: Some("[email protected]"),
39+
..crate::new_user(username)
40+
};
41+
Ok(new_user.create_or_update(conn)?)
42+
})
43+
}
44+
45+
/// Sets the database in read only mode.
46+
///
47+
/// Any attempts to modify the database after calling this function will
48+
/// result in an error.
49+
pub fn set_read_only(&self) -> CargoResult<()> {
50+
self.db(|conn| {
51+
diesel::sql_query("SET TRANSACTION READ ONLY").execute(conn)?;
52+
Ok(())
53+
})
54+
}
55+
56+
/// Create an HTTP `GET` request
57+
pub fn get(&self, path: &str) -> RequestBuilder<'_> {
58+
RequestBuilder::new(&self.middleware, Method::Get, path)
59+
}
60+
61+
/// Create an HTTP `DELETE` request
62+
pub fn delete(&self, path: &str) -> RequestBuilder<'_> {
63+
RequestBuilder::new(&self.middleware, Method::Delete, path)
64+
}
65+
66+
/// Returns the first API token for the given user, or creates a new one
67+
pub fn token_for(&self, user: &User) -> CargoResult<ApiToken> {
68+
self.db(|conn| {
69+
ApiToken::belonging_to(user)
70+
.first(conn)
71+
.optional()?
72+
.map(Ok)
73+
.unwrap_or_else(|| {
74+
ApiToken::insert(conn, user.id, "test_token").map_err(Into::into)
75+
})
76+
})
77+
}
78+
}

src/tests/helpers/request.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use super::response::{Response, ResponseError};
2+
use cargo_registry::models::ApiToken;
3+
use conduit::{Handler, Method};
4+
use conduit_middleware::MiddlewareBuilder;
5+
use conduit_test::MockRequest;
6+
7+
pub struct RequestBuilder<'a> {
8+
middleware: &'a MiddlewareBuilder,
9+
request: MockRequest,
10+
}
11+
12+
impl<'a> RequestBuilder<'a> {
13+
pub(super) fn new(middleware: &'a MiddlewareBuilder, method: Method, path: &str) -> Self {
14+
Self {
15+
middleware,
16+
request: MockRequest::new(method, path),
17+
}
18+
.with_header("User-Agent", "conduit-test")
19+
}
20+
21+
/// Uses the given token for authentication
22+
pub fn with_token(self, token: &ApiToken) -> Self {
23+
self.with_header("Authorization", &token.token)
24+
}
25+
26+
pub fn with_header(mut self, name: &str, value: &str) -> Self {
27+
self.request.header(name, value);
28+
self
29+
}
30+
31+
/// Send the request
32+
///
33+
/// Returns an error if any of the middlewares returned an error, or if
34+
/// the response status was >= 400.
35+
pub fn send(mut self) -> Result<Response, ResponseError> {
36+
Response::new(self.middleware.call(&mut self.request)?)
37+
}
38+
}

src/tests/helpers/response.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use cargo_registry::util::{CargoError, CargoResult};
2+
use std::error::Error;
3+
use std::fmt;
4+
5+
pub struct Response {
6+
inner: conduit::Response,
7+
body: String,
8+
}
9+
10+
impl Response {
11+
pub(super) fn new(mut inner: conduit::Response) -> Result<Self, ResponseError> {
12+
use ResponseError::*;
13+
14+
let mut body = Vec::new();
15+
inner.body.write_body(&mut body).unwrap();
16+
17+
let resp = Response {
18+
inner,
19+
body: String::from_utf8(body).unwrap(),
20+
};
21+
22+
match resp.status() {
23+
400...499 => Err(BadRequest(resp)),
24+
500...599 => Err(ServerError(resp)),
25+
_ => Ok(resp),
26+
}
27+
}
28+
29+
pub fn status(&self) -> u32 {
30+
self.inner.status.0
31+
}
32+
33+
pub fn text(&self) -> &str {
34+
&self.body
35+
}
36+
}
37+
38+
pub enum ResponseError {
39+
MiddlewareError(Box<dyn CargoError>),
40+
BadRequest(Response),
41+
ServerError(Response),
42+
}
43+
44+
impl From<Box<dyn Error + Send>> for ResponseError {
45+
fn from(e: Box<dyn Error + Send>) -> Self {
46+
ResponseError::MiddlewareError(CargoError::from_std_error(e))
47+
}
48+
}
49+
50+
impl fmt::Debug for ResponseError {
51+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
52+
use ResponseError::*;
53+
match self {
54+
MiddlewareError(e) => write!(f, "MiddlewareError({:?})", e),
55+
BadRequest(_) => write!(f, "BadRequest(_)"),
56+
ServerError(_) => write!(f, "ServerError(_)"),
57+
}
58+
}
59+
}
60+
61+
impl fmt::Display for ResponseError {
62+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
63+
use ResponseError::*;
64+
match self {
65+
MiddlewareError(e) => write!(f, "middleware error: {}", e),
66+
BadRequest(e) => write!(f, "bad request: {}", e.text()),
67+
ServerError(e) => write!(f, "server error: {}", e.text()),
68+
}
69+
}
70+
}
71+
72+
impl Error for ResponseError {}
73+
74+
pub trait ResultExt {
75+
fn allow_errors(self) -> CargoResult<Response>;
76+
}
77+
78+
impl ResultExt for Result<Response, ResponseError> {
79+
fn allow_errors(self) -> CargoResult<Response> {
80+
use ResponseError::*;
81+
self.or_else(|e| match e {
82+
MiddlewareError(e) => Err(e),
83+
BadRequest(r) | ServerError(r) => Ok(r),
84+
})
85+
}
86+
}

src/tests/prelude.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub use crate::helpers::app::*;
2+
pub use crate::helpers::response::ResultExt as _;
3+
pub use cargo_registry::util::CargoResult;

src/tests/read_only_mode.rs

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,52 @@
11
use crate::builders::CrateBuilder;
2-
use crate::{RequestHelper, TestApp};
2+
use crate::prelude::*;
33
use diesel::prelude::*;
44

55
#[test]
6-
fn can_hit_read_only_endpoints_in_read_only_mode() {
7-
let (app, anon) = TestApp::init().empty();
8-
app.db(set_read_only).unwrap();
9-
anon.get::<()>("/api/v1/crates").assert_status(200);
6+
fn can_hit_read_only_endpoints_in_read_only_mode() -> CargoResult<()> {
7+
let app = App::new();
8+
app.set_read_only()?;
9+
app.get("/api/v1/crates").send()?;
10+
Ok(())
1011
}
1112

1213
#[test]
13-
fn cannot_hit_endpoint_which_writes_db_in_read_only_mode() {
14-
let (app, _, user, token) = TestApp::init().with_token();
14+
fn cannot_hit_endpoint_which_writes_db_in_read_only_mode() -> CargoResult<()> {
15+
let app = App::new();
16+
let user = app.create_user("new_user")?;
17+
let token = app.token_for(&user)?;
18+
1519
app.db(|conn| {
16-
CrateBuilder::new("foo_yank_read_only", user.as_model().id)
20+
CrateBuilder::new("foo_yank_read_only", user.id)
1721
.version("1.0.0")
18-
.expect_build(conn);
19-
set_read_only(conn).unwrap();
20-
});
21-
token
22-
.delete::<()>("/api/v1/crates/foo_yank_read_only/1.0.0/yank")
23-
.assert_status(503);
22+
.build(conn)
23+
})?;
24+
app.set_read_only()?;
25+
let resp = app
26+
.delete("/api/v1/crates/foo_yank_read_only/1.0.0/yank")
27+
.with_token(&token)
28+
.send()
29+
.allow_errors()?;
30+
assert_eq!(503, resp.status());
31+
Ok(())
2432
}
2533

2634
#[test]
27-
fn can_download_crate_in_read_only_mode() {
28-
let (app, anon, user) = TestApp::with_proxy().with_user();
35+
fn can_download_crate_in_read_only_mode() -> CargoResult<()> {
36+
let app = App::new();
2937

3038
app.db(|conn| {
31-
CrateBuilder::new("foo_download_read_only", user.as_model().id)
39+
let user = app.create_user("new_user")?;
40+
CrateBuilder::new("foo_download_read_only", user.id)
3241
.version("1.0.0")
33-
.expect_build(conn);
34-
set_read_only(conn).unwrap();
35-
});
42+
.build(conn)
43+
})?;
44+
app.set_read_only()?;
3645

37-
anon.get::<()>("/api/v1/crates/foo_download_read_only/1.0.0/download")
38-
.assert_status(302);
46+
let resp = app
47+
.get("/api/v1/crates/foo_download_read_only/1.0.0/download")
48+
.send()?;
49+
assert_eq!(302, resp.status());
3950

4051
// We're in read only mode so the download should not have been counted
4152
app.db(|conn| {
@@ -44,12 +55,8 @@ fn can_download_crate_in_read_only_mode() {
4455

4556
let dl_count = version_downloads
4657
.select(sum(downloads))
47-
.get_result::<Option<i64>>(conn);
48-
assert_eq!(Ok(None), dl_count);
58+
.get_result::<Option<i64>>(conn)?;
59+
assert_eq!(None, dl_count);
60+
Ok(())
4961
})
5062
}
51-
52-
fn set_read_only(conn: &PgConnection) -> QueryResult<()> {
53-
diesel::sql_query("SET TRANSACTION READ ONLY").execute(conn)?;
54-
Ok(())
55-
}

0 commit comments

Comments
 (0)