diff --git a/examples/todo/Cargo.toml b/examples/todo/Cargo.toml index 28d3cf5c..ae2bac37 100644 --- a/examples/todo/Cargo.toml +++ b/examples/todo/Cargo.toml @@ -14,6 +14,10 @@ diesel = { version = "0.12", features = ["sqlite"] } diesel_codegen = { version = "0.12", features = ["sqlite"] } r2d2-diesel = "0.12" +[dev-dependencies] +parking_lot = {version = "0.4", features = ["nightly"]} +rand = "0.3" + [dependencies.rocket_contrib] path = "../../contrib" default_features = false diff --git a/examples/todo/db/seed.sql b/examples/todo/db/seed.sql index bfc1cdaa..23f72530 100644 --- a/examples/todo/db/seed.sql +++ b/examples/todo/db/seed.sql @@ -1,7 +1,7 @@ CREATE TABLE tasks ( - id INTEGER PRIMARY KEY, - description VARCHAR NOT NULL, - completed BOOLEAN NOT NULL DEFAULT 0 + id INTEGER PRIMARY KEY AUTOINCREMENT, + description VARCHAR NOT NULL, + completed BOOLEAN NOT NULL DEFAULT 0 ); -INSERT INTO tasks (description) VALUES ("my first task"); +INSERT INTO tasks (description) VALUES ("demo task"); diff --git a/examples/todo/migrations/20160720150332_create_tasks_table/up.sql b/examples/todo/migrations/20160720150332_create_tasks_table/up.sql index 966519cf..6f824598 100644 --- a/examples/todo/migrations/20160720150332_create_tasks_table/up.sql +++ b/examples/todo/migrations/20160720150332_create_tasks_table/up.sql @@ -1,5 +1,5 @@ CREATE TABLE tasks ( - id INTEGER PRIMARY KEY, - description VARCHAR NOT NULL, - completed BOOLEAN DEFAULT 0 + id INTEGER PRIMARY KEY AUTOINCREMENT, + description VARCHAR NOT NULL, + completed BOOLEAN NOT NULL DEFAULT 0 ) diff --git a/examples/todo/src/db.rs b/examples/todo/src/db.rs index 13197cd4..542d7455 100644 --- a/examples/todo/src/db.rs +++ b/examples/todo/src/db.rs @@ -18,11 +18,12 @@ pub fn init_pool() -> Pool { r2d2::Pool::new(config, manager).expect("db pool") } -pub struct Conn(r2d2::PooledConnection>); +pub struct Conn(pub r2d2::PooledConnection>); impl Deref for Conn { type Target = SqliteConnection; + #[inline(always)] fn deref(&self) -> &Self::Target { &self.0 } diff --git a/examples/todo/src/main.rs b/examples/todo/src/main.rs index 2454b792..8cf61cef 100644 --- a/examples/todo/src/main.rs +++ b/examples/todo/src/main.rs @@ -1,4 +1,4 @@ -#![feature(plugin, custom_derive)] +#![feature(plugin, custom_derive, const_fn)] #![plugin(rocket_codegen)] extern crate rocket; @@ -13,12 +13,14 @@ extern crate r2d2_diesel; mod static_files; mod task; mod db; +#[cfg(test)] mod tests; +use rocket::Rocket; use rocket::request::{Form, FlashMessage}; use rocket::response::{Flash, Redirect}; use rocket_contrib::Template; -use task::Task; +use task::{Task, Todo}; #[derive(Debug, Serialize)] struct Context<'a, 'b>{ msg: Option<(&'a str, &'b str)>, tasks: Vec } @@ -34,11 +36,11 @@ impl<'a, 'b> Context<'a, 'b> { } #[post("/", data = "")] -fn new(todo_form: Form, conn: db::Conn) -> Flash { +fn new(todo_form: Form, conn: db::Conn) -> Flash { let todo = todo_form.into_inner(); if todo.description.is_empty() { Flash::error(Redirect::to("/"), "Description cannot be empty.") - } else if todo.insert(&conn) { + } else if Task::insert(todo, &conn) { Flash::success(Redirect::to("/"), "Todo successfully added.") } else { Flash::error(Redirect::to("/"), "Whoops! The server failed.") @@ -71,11 +73,23 @@ fn index(msg: Option, conn: db::Conn) -> Template { }) } -fn main() { - rocket::ignite() +fn rocket() -> (Rocket, Option) { + let pool = db::init_pool(); + let conn = if cfg!(test) { + Some(db::Conn(pool.get().expect("database connection for testing"))) + } else { + None + }; + + let rocket = rocket::ignite() .manage(db::init_pool()) .mount("/", routes![index, static_files::all]) .mount("/todo/", routes![new, toggle, delete]) - .attach(Template::fairing()) - .launch(); + .attach(Template::fairing()); + + (rocket, conn) +} + +fn main() { + rocket().0.launch(); } diff --git a/examples/todo/src/task.rs b/examples/todo/src/task.rs index e470f6bf..6f29623d 100644 --- a/examples/todo/src/task.rs +++ b/examples/todo/src/task.rs @@ -10,11 +10,16 @@ mod schema { } #[table_name = "tasks"] -#[derive(Serialize, Queryable, Insertable, FromForm, Debug, Clone)] +#[derive(Serialize, Queryable, Insertable, Debug, Clone)] pub struct Task { - id: Option, + pub id: Option, + pub description: String, + pub completed: bool +} + +#[derive(FromForm)] +pub struct Todo { pub description: String, - pub completed: Option } impl Task { @@ -22,8 +27,9 @@ impl Task { all_tasks.order(tasks::id.desc()).load::(conn).unwrap() } - pub fn insert(&self, conn: &SqliteConnection) -> bool { - diesel::insert(self).into(tasks::table).execute(conn).is_ok() + pub fn insert(todo: Todo, conn: &SqliteConnection) -> bool { + let t = Task { id: None, description: todo.description, completed: false }; + diesel::insert(&t).into(tasks::table).execute(conn).is_ok() } pub fn toggle_with_id(id: i32, conn: &SqliteConnection) -> bool { @@ -32,7 +38,7 @@ impl Task { return false; } - let new_status = !task.unwrap().completed.unwrap(); + let new_status = !task.unwrap().completed; let updated_task = diesel::update(all_tasks.find(id)); updated_task.set(task_completed.eq(new_status)).execute(conn).is_ok() } diff --git a/examples/todo/src/tests.rs b/examples/todo/src/tests.rs new file mode 100644 index 00000000..fcb92b96 --- /dev/null +++ b/examples/todo/src/tests.rs @@ -0,0 +1,144 @@ +extern crate parking_lot; +extern crate rand; + +use super::task::Task; +use self::parking_lot::Mutex; +use self::rand::{Rng, thread_rng}; + +use rocket::testing::MockRequest; +use rocket::http::Method::*; +use rocket::http::{Status, ContentType}; + +// We use a lock to synchronize between tests so DB operations don't collide. +// For now. In the future, we'll have a nice way to run each test in a DB +// transaction so we can regain concurrency. +static DB_LOCK: Mutex<()> = Mutex::new(()); + +macro_rules! run_test { + (|$rocket:ident, $conn:ident| $block:expr) => ({ + let _lock = DB_LOCK.lock(); + let ($rocket, db) = super::rocket(); + let $conn = db.expect("failed to get database connection for testing"); + $block + }) +} + +#[test] +fn test_insertion_deletion() { + run_test!(|rocket, conn| { + // Get the tasks before making changes. + let init_tasks = Task::all(&conn); + + // Issue a request to insert a new task. + let mut req = MockRequest::new(Post, "/todo") + .header(ContentType::Form) + .body("description=My+first+task"); + req.dispatch_with(&rocket); + + // Ensure we have one more task in the database. + let new_tasks = Task::all(&conn); + assert_eq!(new_tasks.len(), init_tasks.len() + 1); + + // Ensure the task is what we expect. + assert_eq!(new_tasks[0].description, "My first task"); + assert_eq!(new_tasks[0].completed, false); + + // Issue a request to delete the task. + let id = new_tasks[0].id.unwrap(); + let mut req = MockRequest::new(Delete, format!("/todo/{}", id)); + req.dispatch_with(&rocket); + + // Ensure it's gone. + let final_tasks = Task::all(&conn); + assert_eq!(final_tasks.len(), init_tasks.len()); + assert_ne!(final_tasks[0].description, "My first task"); + }) +} + +#[test] +fn test_toggle() { + run_test!(|rocket, conn| { + // Issue a request to insert a new task; ensure it's not yet completed. + let mut req = MockRequest::new(Post, "/todo") + .header(ContentType::Form) + .body("description=test_for_completion"); + req.dispatch_with(&rocket); + + let task = Task::all(&conn)[0].clone(); + assert_eq!(task.completed, false); + + // Issue a request to toggle the task; ensure it is completed. + let mut req = MockRequest::new(Put, format!("/todo/{}", task.id.unwrap())); + req.dispatch_with(&rocket); + assert_eq!(Task::all(&conn)[0].completed, true); + + // Issue a request to toggle the task; ensure it's not completed again. + let mut req = MockRequest::new(Put, format!("/todo/{}", task.id.unwrap())); + req.dispatch_with(&rocket); + assert_eq!(Task::all(&conn)[0].completed, false); + }) +} + +#[test] +fn test_many_insertions() { + const ITER: usize = 100; + + let mut rng = thread_rng(); + run_test!(|rocket, conn| { + // Get the number of tasks initially. + let init_num = Task::all(&conn).len(); + let mut descs = Vec::new(); + + for i in 0..ITER { + // Issue a request to insert a new task with a random description. + let desc: String = rng.gen_ascii_chars().take(12).collect(); + let mut req = MockRequest::new(Post, "/todo") + .header(ContentType::Form) + .body(format!("description={}", desc)); + req.dispatch_with(&rocket); + + // Record the description we choose for this iteration. + descs.insert(0, desc); + + // Ensure the task was inserted properly and all other tasks remain. + let tasks = Task::all(&conn); + assert_eq!(tasks.len(), init_num + i + 1); + + for j in 0..i { + assert_eq!(descs[j], tasks[j].description); + } + } + }) +} + +#[test] +fn test_bad_form_submissions() { + run_test!(|rocket, _conn| { + // Submit an empty form. We should get a 422 but no flash error. + let mut req = MockRequest::new(Post, "/todo").header(ContentType::Form); + let res = req.dispatch_with(&rocket); + assert_eq!(res.status(), Status::UnprocessableEntity); + let mut cookies = res.headers().get("Set-Cookie"); + assert!(!cookies.any(|value| value.contains("error"))); + + // Submit a form with an empty description. + let mut req = MockRequest::new(Post, "/todo") + .header(ContentType::Form) + .body("description="); + + // We look for 'error' in the cookies which corresponds to flash message + // being set as an error. + let res = req.dispatch_with(&rocket); + let mut cookies = res.headers().get("Set-Cookie"); + assert!(cookies.any(|value| value.contains("error"))); + + // Submit a form without a description. Expect a 422 but no flash error. + let mut req = MockRequest::new(Post, "/todo") + .header(ContentType::Form) + .body("evil=smile"); + let res = req.dispatch_with(&rocket); + assert_eq!(res.status(), Status::UnprocessableEntity); + let mut cookies = res.headers().get("Set-Cookie"); + assert!(!cookies.any(|value| value.contains("error"))); + }) +}