diff --git a/server/src/main.rs b/server/src/main.rs index 3e5b7ea..589a67a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -61,6 +61,7 @@ fn rocket_from_config(figment: Figment) -> Rocket { games_routes::create_game, games_routes::get_all_games, games_routes::join_game, + games_routes::leave_game, ], ) .mount( diff --git a/server/src/responses.rs b/server/src/responses.rs index 413f3bb..08c199a 100644 --- a/server/src/responses.rs +++ b/server/src/responses.rs @@ -41,7 +41,7 @@ impl OpenApiResponderInner for JoinGameError { "409".to_string(), RefOr::Object(OpenApiResponse { description: "\ - # [409 Conflicted](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409)\n\ + # [409 Conflict](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409)\n\ That user is already part of this game.\ " .to_string(), @@ -75,6 +75,65 @@ impl<'r> Responder<'r, 'static> for JoinGameError { } } +#[derive(Debug, Serialize, JsonSchema)] +pub struct LeaveGameError { + pub message: String, + #[serde(skip)] + pub http_status_code: u16, +} + +impl OpenApiResponderInner for LeaveGameError { + fn responses(_generator: &mut OpenApiGenerator) -> Result { + let mut responses = Map::new(); + responses.insert( + "404".to_string(), + RefOr::Object(OpenApiResponse { + description: "\ + # [404 Not Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404)\n\ + A game with that ID doesn't exist.\ + " + .to_string(), + ..Default::default() + }), + ); + responses.insert( + "409".to_string(), + RefOr::Object(OpenApiResponse { + description: "\ + # [409 Conflict](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409)\n\ + That user isn't part of this game.\ + " + .to_string(), + ..Default::default() + }), + ); + Ok(Responses { + responses, + ..Default::default() + }) + } +} + +impl std::fmt::Display for LeaveGameError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "Join game error `{}`", self.message,) + } +} + +impl std::error::Error for LeaveGameError {} + +impl<'r> Responder<'r, 'static> for LeaveGameError { + fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { + // Convert object to json + let body = serde_json::to_string(&self).unwrap(); + Response::build() + .sized_body(body.len(), std::io::Cursor::new(body)) + .header(ContentType::JSON) + .status(Status::new(self.http_status_code)) + .ok() + } +} + #[derive(Serialize, Deserialize, JsonSchema)] pub struct GamesResponse { pub games: Vec, diff --git a/server/src/routes/games.rs b/server/src/routes/games.rs index f542f98..c2d9621 100644 --- a/server/src/routes/games.rs +++ b/server/src/routes/games.rs @@ -1,5 +1,5 @@ use crate::{ - responses::{GameResponse, GamesResponse, JoinGameError, MessageResponse}, + responses::{GameResponse, GamesResponse, JoinGameError, LeaveGameError, MessageResponse}, services::{GameService, UserService}, users::User, }; @@ -85,6 +85,32 @@ pub async fn join_game( } } +#[openapi(tag = "Games")] +#[patch("/games//leave")] +pub async fn leave_game( + game_id: u32, + user: User, + games: &State, +) -> Result, LeaveGameError> { + if let Some(game) = games.get(game_id) { + match games.leave(game.id, user.id) { + Ok(_) => Ok(Json(MessageResponse { + message: "left the game successfully".into(), + r#type: "success".into(), + })), + Err(e) => Err(LeaveGameError { + message: e.into(), + http_status_code: 409, + }), + } + } else { + Err(LeaveGameError { + message: "game not found".into(), + http_status_code: 404, + }) + } +} + #[cfg(test)] mod tests { use crate::{ @@ -246,7 +272,146 @@ mod tests { .dispatch() .await .status(), + Status::Conflict + ); + } + + #[sqlx::test] + async fn can_leave_game() { + let client = mocked_client().await; + + create_test_users(&client).await; + + let user = client + .post(uri!(user_routes::user_login)) + .header(ContentType::JSON) + .body( + serde_json::to_string(&UserLoginPayload { + username: "testuser1".into(), + password: "abc1234".into(), // don't do this in practice! + }) + .unwrap(), + ) + .dispatch() + .await; + + let game = client + .post(uri!(super::create_game)) + .private_cookie(user.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + assert_eq!( + client + .patch(uri!(super::leave_game( + game_id = game.into_json::().await.unwrap().id + ))) + .private_cookie(user.cookies().get_private("login").unwrap()) + .dispatch() + .await + .status(), + Status::Ok + ); + } + + #[sqlx::test] + async fn cannot_leave_game_twice() { + let client = mocked_client().await; + + create_test_users(&client).await; + + let user = client + .post(uri!(user_routes::user_login)) + .header(ContentType::JSON) + .body( + serde_json::to_string(&UserLoginPayload { + username: "testuser1".into(), + password: "abc1234".into(), // don't do this in practice! + }) + .unwrap(), + ) + .dispatch() + .await; + + let game_id = client + .post(uri!(super::create_game)) + .private_cookie(user.cookies().get_private("login").unwrap()) + .dispatch() + .await + .into_json::() + .await + .unwrap() + .id; + + assert_eq!( + client + .patch(uri!(super::leave_game(game_id = game_id))) + .private_cookie(user.cookies().get_private("login").unwrap()) + .dispatch() + .await + .status(), + Status::Ok + ); + + assert_eq!( + client + .patch(uri!(super::leave_game(game_id = game_id))) + .private_cookie(user.cookies().get_private("login").unwrap()) + .dispatch() + .await + .status(), Status::NotFound ); } + + #[sqlx::test] + async fn cannot_leave_game_without_joining() { + let client = mocked_client().await; + + create_test_users(&client).await; + + let response = client + .post(uri!(user_routes::user_login)) + .header(ContentType::JSON) + .body( + serde_json::to_string(&UserLoginPayload { + username: "testuser1".into(), + password: "abc1234".into(), // don't do this in practice! + }) + .unwrap(), + ) + .dispatch() + .await; + + let game = client + .post(uri!(super::create_game)) + .private_cookie(response.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + let response = client + .post(uri!(user_routes::user_login)) + .header(ContentType::JSON) + .body( + serde_json::to_string(&UserLoginPayload { + username: "testuser2".into(), + password: "abc1234".into(), // don't do this in practice! + }) + .unwrap(), + ) + .dispatch() + .await; + + assert_eq!( + client + .patch(uri!(super::leave_game( + game_id = game.into_json::().await.unwrap().id + ))) + .private_cookie(response.cookies().get_private("login").unwrap()) + .dispatch() + .await + .status(), + Status::Conflict + ); + } } diff --git a/server/src/routes/users.rs b/server/src/routes/users.rs index 8d8a598..dd8fb7c 100644 --- a/server/src/routes/users.rs +++ b/server/src/routes/users.rs @@ -1,6 +1,6 @@ use crate::{ responses::{MessageResponse, UsersResponse}, - services::UserService, + services::{GameService, UserService}, users::{User, UserLoginPayload}, HitsterConfig, }; @@ -69,7 +69,7 @@ pub async fn user_login( mut db: Connection, cookies: &CookieJar<'_>, users: &State, -) -> Result, NotFound>> { +) -> Result, NotFound>> { let mut hasher = Sha3_512::new(); hasher.update(credentials.password.as_bytes()); credentials.password = format!("{:?}", hex::encode(hasher.finalize())); @@ -81,10 +81,7 @@ pub async fn user_login( serde_json::to_string(&*credentials).unwrap(), )); - Ok(Json(MessageResponse { - message: "logged in successfully".into(), - r#type: "success".into(), - })) + Ok(Json(user)) } else { Err(NotFound(Json(MessageResponse { message: "incorrect user credentials".into(), @@ -101,23 +98,20 @@ pub async fn user_login( match user { Some(user) => { - if users.get_by_id(user.get("id")).is_none() { - users.add(User { - id: user.get::("id"), - username: user.get::("username"), - password: user.get::("password"), - }); - } + let u = User { + id: user.get::("id"), + username: user.get::("username"), + password: user.get::("password"), + }; + + users.add(u.clone()); cookies.add_private(Cookie::new( "login", serde_json::to_string(&*credentials).unwrap(), )); - Ok(Json(MessageResponse { - message: "logged in successfully".into(), - r#type: "success".into(), - })) + Ok(Json(u)) } None => Err(NotFound(Json(MessageResponse { message: "incorrect user credentials".into(), @@ -188,8 +182,13 @@ pub async fn user_signup( pub async fn user_logout( user: User, users: &State, + games: &State, cookies: &CookieJar<'_>, ) -> Json { + for game in games.get_all().iter() { + let _ = games.leave(game.id, user.id); + } + cookies.remove_private("login"); users.remove(user.id); @@ -202,7 +201,8 @@ pub async fn user_logout( #[cfg(test)] pub mod tests { use crate::{ - responses::UsersResponse, + responses::{GameResponse, GamesResponse, UsersResponse}, + routes::games as games_routes, test::mocked_client, users::{User, UserLoginPayload}, }; @@ -464,4 +464,202 @@ pub mod tests { Status::Unauthorized ); } + + #[sqlx::test] + async fn leave_game_when_logging_out() { + let client = mocked_client().await; + + create_test_users(&client).await; + + let user1 = client + .post(uri!(super::user_login)) + .header(ContentType::JSON) + .body( + serde_json::to_string(&UserLoginPayload { + username: "testuser1".into(), + password: "abc1234".into(), + }) + .unwrap(), + ) + .dispatch() + .await; + + let user2 = client + .post(uri!(super::user_login)) + .header(ContentType::JSON) + .body( + serde_json::to_string(&UserLoginPayload { + username: "testuser2".into(), + password: "abc1234".into(), + }) + .unwrap(), + ) + .dispatch() + .await; + + let game = client + .post(uri!(games_routes::create_game)) + .private_cookie(user1.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + client + .patch(uri!(games_routes::join_game( + game_id = game.into_json::().await.unwrap().id + ))) + .private_cookie(user2.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + assert_eq!( + client + .get(uri!(games_routes::get_all_games)) + .dispatch() + .await + .into_json::() + .await + .unwrap() + .games + .get(0) + .unwrap() + .players + .len(), + 2 + ); + + client + .post(uri!(super::user_logout)) + .private_cookie(user2.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + assert_eq!( + client + .get(uri!(games_routes::get_all_games)) + .dispatch() + .await + .into_json::() + .await + .unwrap() + .games + .get(0) + .unwrap() + .players + .len(), + 1 + ); + } + + #[sqlx::test] + async fn select_new_game_creator_after_logging_out() { + let client = mocked_client().await; + + create_test_users(&client).await; + + let user1 = client + .post(uri!(super::user_login)) + .header(ContentType::JSON) + .body( + serde_json::to_string(&UserLoginPayload { + username: "testuser1".into(), + password: "abc1234".into(), + }) + .unwrap(), + ) + .dispatch() + .await; + + let user2 = client + .post(uri!(super::user_login)) + .header(ContentType::JSON) + .body( + serde_json::to_string(&UserLoginPayload { + username: "testuser2".into(), + password: "abc1234".into(), + }) + .unwrap(), + ) + .dispatch() + .await; + + let game = client + .post(uri!(games_routes::create_game)) + .private_cookie(user1.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + client + .patch(uri!(games_routes::join_game( + game_id = game.into_json::().await.unwrap().id + ))) + .private_cookie(user2.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + client + .post(uri!(super::user_logout)) + .private_cookie(user1.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + assert_eq!( + client + .get(uri!(games_routes::get_all_games)) + .dispatch() + .await + .into_json::() + .await + .unwrap() + .games + .get(0) + .unwrap() + .creator, + user2.into_json::().await.unwrap() + ); + } + + #[sqlx::test] + async fn delete_game_after_last_player_logs_out() { + let client = mocked_client().await; + + create_test_users(&client).await; + + let user = client + .post(uri!(super::user_login)) + .header(ContentType::JSON) + .body( + serde_json::to_string(&UserLoginPayload { + username: "testuser1".into(), + password: "abc1234".into(), + }) + .unwrap(), + ) + .dispatch() + .await; + + client + .post(uri!(games_routes::create_game)) + .private_cookie(user.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + client + .post(uri!(super::user_logout)) + .private_cookie(user.cookies().get_private("login").unwrap()) + .dispatch() + .await; + + assert_eq!( + client + .get(uri!(games_routes::get_all_games)) + .dispatch() + .await + .into_json::() + .await + .unwrap() + .games + .len(), + 0 + ); + } } diff --git a/server/src/services/games.rs b/server/src/services/games.rs index 1cb8d09..e2167c5 100644 --- a/server/src/services/games.rs +++ b/server/src/services/games.rs @@ -64,4 +64,27 @@ impl GameService { Err("game not found") } } + + pub fn leave(&self, game_id: u32, user_id: u32) -> Result<(), &'static str> { + let mut data = self.data.lock().unwrap(); + + if let Some(game) = data.games.get_mut(&game_id) { + if !game.players.contains(&user_id) { + Err("user is not part of this game") + } else { + game.players + .swap_remove(game.players.iter().position(|u| *u == user_id).unwrap()); + + if game.players.len() == 0 { + data.games.remove(&game_id); + } else if game.creator == user_id { + game.creator = *game.players.first().unwrap(); + } + + Ok(()) + } + } else { + Err("game not found") + } + } }