This commit is contained in:
Martin Berg Alstad 2024-12-17 17:05:43 +00:00
parent 8e728cca58
commit f7036a18a0
10 changed files with 145 additions and 235 deletions

2
.idea/dataSources.xml generated
View File

@ -5,7 +5,7 @@
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:32784/postgres</jdbc-url>
<jdbc-url>jdbc:postgresql://localhost:32769/postgres</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>

19
Cargo.lock generated
View File

@ -441,18 +441,19 @@ dependencies = [
[[package]]
name = "bon"
version = "2.0.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea8256e3cff531086cc3faf94c1649930ff64bceb2d0e8cc84fc0356d7ee9806"
checksum = "811d7882589e047896e5974d039dd8823a67973a63d559e6ad1e87ff5c42ed4f"
dependencies = [
"bon-macros",
"rustversion",
]
[[package]]
name = "bon-macros"
version = "2.0.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b99838f77c5073bc7846ecce92b64e7e5a5bd152a8ec392facf90ee4d90b4b35"
checksum = "d8e745a763e579a5ce70130e66f9dd35abf77cfeb9f418f305aeab8d1ae54c43"
dependencies = [
"darling",
"ident_case",
@ -1509,7 +1510,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
name = "lib"
version = "1.4.3"
dependencies = [
"async-trait",
"axum",
"bon",
"chrono",
"deadpool-diesel",
"derive_more",
@ -1517,9 +1520,12 @@ dependencies = [
"diesel-async",
"diesel-crud-derive",
"diesel-crud-trait",
"diesel_async_migrations",
"into-response-derive",
"mime",
"serde",
"serde_json",
"testcontainers-modules",
"thiserror",
"tokio",
"tower 0.5.0",
@ -2841,15 +2847,14 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.5.2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97"
dependencies = [
"bitflags 2.6.0",
"bytes",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",

View File

@ -45,6 +45,7 @@ lib = { path = "../lib", features = ["axum", "serde", "derive", "diesel", "time"
#lib = { git = "https://github.com/emberal/rust-lib", tag = "1.4.3", features = ["axum", "serde", "derive"] }
[dev-dependencies]
lib = { path = "../lib", features = ["test"] }
rstest = "0.22.0"
testcontainers-modules = { version = "0.10.0", features = ["postgres"] }
async-std = { version = "1.12.0", features = ["attributes"] }

View File

@ -1,35 +1,10 @@
use crate::config;
use crate::error::AppError;
use axum::async_trait;
use deadpool_diesel::postgres::BuildError;
use deadpool_diesel::Status;
use diesel_async::pooled_connection::deadpool::{Object, Pool};
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_async::AsyncPgConnection;
pub type PgPool = Pool<AsyncPgConnection>;
#[async_trait]
pub trait GetConnection: Clone + Send + Sync {
async fn get(&self) -> Result<Object<AsyncPgConnection>, AppError>;
fn status(&self) -> Status;
}
#[async_trait]
impl GetConnection for PgPool {
async fn get(&self) -> Result<Object<AsyncPgConnection>, AppError> {
self.get().await.map_err(Into::into)
}
fn status(&self) -> Status {
self.status()
}
}
use lib::diesel::pool::PgPool;
pub(crate) fn create_pool() -> Result<PgPool, BuildError> {
create_pool_from_url(config::DATABASE_URL)
}
pub(crate) fn create_pool_from_url(url: impl Into<String>) -> Result<PgPool, BuildError> {
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(url);
Pool::builder(config).max_size(config::POOL_SIZE).build()
lib::diesel::pool::create_pool()
.url(config::DATABASE_URL)
.size(config::POOL_SIZE)
.call()
}

View File

@ -1,11 +1,11 @@
use crate::services::reservation_service::ReservationError;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum_login::AuthnBackend;
use derive_more::Constructor;
use diesel::result::DatabaseErrorKind;
use diesel_async::pooled_connection::deadpool;
use crate::services::reservation_service::ReservationError;
use lib::diesel::get_connection::GetConnectionError;
use lib::diesel_crud_trait::CrudError;
use lib::into_response_derive::IntoResponse;
use serde::{Deserialize, Serialize};
@ -96,6 +96,15 @@ impl IntoResponse for ResponseError {
}
}
impl From<GetConnectionError> for ResponseError {
fn from(value: GetConnectionError) -> Self {
match value {
GetConnectionError::PoolError(error) => Self::InternalServerError(error.to_string()),
GetConnectionError::DieselError(error) => Self::InternalServerError(error.to_string()),
}
}
}
impl From<CrudError> for ResponseError {
fn from(value: CrudError) -> Self {
match &value {
@ -156,6 +165,15 @@ impl IntoResponse for AppError {
}
}
impl From<GetConnectionError> for AppError {
fn from(value: GetConnectionError) -> Self {
match value {
GetConnectionError::PoolError(error) => Self::PoolError(error),
GetConnectionError::DieselError(error) => Self::DatabaseError(error),
}
}
}
impl From<base64ct::Error> for AppError {
fn from(value: base64ct::Error) -> Self {
Self::Base64Error(value.to_string())

View File

@ -2,13 +2,16 @@
#[macro_use]
extern crate rstest;
use crate::database::{create_pool, GetConnection};
use crate::database::create_pool;
use crate::services::session_service::SessionService;
use crate::services::user_service::UserService;
use axum::middleware::{from_fn, Next};
use axum::response::IntoResponse;
use axum::Router;
use axum_login::tower_sessions::SessionManagerLayer;
use axum_login::AuthManagerLayerBuilder;
use lib::axum::app::AppBuilder;
use lib::diesel::get_connection::GetConnection;
use tower_sessions::cookie::time::Duration;
use tower_sessions::Expiry;
@ -73,7 +76,7 @@ async fn main() {
trait LoginRequired {
fn login_required<Pool>(self) -> Self
where
Pool: GetConnection + Send + Sync + 'static;
Pool: GetConnection + 'static;
}
impl<S> LoginRequired for Router<S>
@ -84,10 +87,6 @@ where
where
Pool: GetConnection + Send + Sync + 'static,
{
use axum_login::axum::{
middleware::{from_fn, Next},
response::IntoResponse,
};
self.route_layer(from_fn(
|auth_session: axum_login::AuthSession<UserService<Pool>>, req, next: Next| async move {
if auth_session.user.is_some() {

View File

@ -1,5 +1,5 @@
use crate::auth::AuthSession;
use crate::database::GetConnection;
use crate::error::ResponseError;
use crate::models::user::{CreateUser, User, UserCredentials};
use crate::result::{ResponseResult, Success};
use crate::services::user_service::UserService;
@ -7,17 +7,17 @@ use crate::{bad_request, internal_server_error, ok};
use axum::extract::State;
use axum::Json;
use axum_valid::Valid;
use lib::diesel::get_connection::GetConnection;
// router!(
// "/auth",
// routes!(
// post "/login" => login::<Pool>,
// post "/register" => register::<Pool>
// ),
// Pool: Clone, Send, Sync, GetConnection -> UserService
// Pool: GetConnection -> UserService
// );
pub fn router<Pool: Clone + Send + Sync + GetConnection + 'static>(
) -> axum::Router<UserService<Pool>> {
pub fn router<Pool: GetConnection + 'static>() -> axum::Router<UserService<Pool>> {
axum::Router::new().nest(
"/auth",
axum::Router::new()
@ -53,7 +53,7 @@ where
.insert(create_user)
.await
.map(Success::Created)
.map_err(Into::into)
.map_err(ResponseError::from)
}
#[cfg(test)]
@ -61,26 +61,51 @@ mod tests {
use crate::create_app;
use crate::error::ErrorResponseBody;
use crate::models::user::{CreateUser, User, UserRole};
use crate::test::{create_test_containers_pool, create_test_pool, BuildJson, DeserializeInto};
use crate::test::create_test_containers_pool;
use axum::http::request::Builder;
use axum::http::{Request, StatusCode};
use axum::Router;
use futures::executor::block_on;
use lib::axum::traits::BuildJson;
use lib::serde::traits::DeserializeInto;
use secrecy::ExposeSecret;
use serde::ser::SerializeStruct;
use serde::{Serialize, Serializer};
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::ContainerAsync;
use tower::ServiceExt;
fn register() -> Builder {
Request::builder().uri("/auth/register").method("POST")
}
#[tokio::test]
async fn test_register_created() {
let pool = create_test_pool().await.unwrap();
let app = create_app(pool);
let create_user = CreateUser::new("test@email.com", "password");
#[fixture]
#[once]
fn setup() -> Setup {
block_on(async {
let test_container = create_test_containers_pool().await.unwrap();
let app = create_app(test_container.pool);
Setup {
_container: test_container.container,
app,
}
})
}
struct Setup {
_container: ContainerAsync<Postgres>,
app: Router,
}
#[rstest]
#[tokio::test(flavor = "multi_thread")]
async fn test_register_created(setup: &Setup) {
let create_user = CreateUser::new("test_register_created@email.com", "password");
let create_email = create_user.email.clone();
let response = app
let response = setup
.app
.clone()
.oneshot(register().json(create_user).unwrap())
.await
.unwrap();
@ -95,15 +120,18 @@ mod tests {
assert!(!user.salt.is_empty());
}
#[tokio::test]
async fn test_register_email_already_registered() {
let test_container = create_test_containers_pool().await.unwrap();
let app = create_app(test_container.pool);
let create_user = CreateUser::new("test@email.com", "password");
#[rstest]
#[tokio::test(flavor = "multi_thread")]
async fn test_register_email_already_registered(setup: &Setup) {
let create_user = CreateUser::new(
"test_register_email_already_registered@email.com",
"password",
);
let call = || async {
app.clone()
setup
.app
.clone()
.oneshot(register().json(create_user.clone()).unwrap())
.await
.unwrap()
@ -129,7 +157,7 @@ mod tests {
where
S: Serializer,
{
let mut s = serializer.serialize_struct("CreateUser", 2)?;
let mut s = serializer.serialize_struct("CreateUser", 3)?;
s.serialize_field("email", &self.email)?;
s.serialize_field("password", &self.password.expose_secret())?;
s.serialize_field("role", &self.role)?;

View File

@ -115,28 +115,27 @@ mod tests {
use crate::models::room::Room;
use crate::models::user::{CreateUser, User};
use crate::schema::{hotel, room, user};
use crate::test::setup_test_transaction;
use crate::test::create_test_containers_pool;
use chrono::{Duration, Utc};
use diesel::dsl::insert_into;
use diesel_async::AsyncPgConnection;
use futures::executor::block_on;
use lib::diesel::pool::PgPool;
use lib::diesel_crud_trait::{DieselCrudCreate, DieselCrudRead};
use secrecy::SecretString;
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::ContainerAsync;
#[rstest]
#[tokio::test]
async fn test_check_in(#[future] setup: Setup) {
let Setup {
mut conn,
reservation,
..
} = setup.await;
Reservation::check_in(reservation.id, &mut conn)
#[tokio::test(flavor = "multi_thread")]
async fn test_check_in(setup: &Setup) {
let mut conn = setup.pool.get().await.unwrap();
Reservation::check_in(setup.reservation.id, &mut conn)
.await
.unwrap();
assert!(
Reservation::read(reservation.id, &mut conn)
Reservation::read(setup.reservation.id, &mut conn)
.await
.unwrap()
.checked_in
@ -144,20 +143,15 @@ mod tests {
}
#[rstest]
#[tokio::test]
async fn test_check_out(#[future] setup: Setup) {
let Setup {
mut conn,
reservation,
..
} = setup.await;
Reservation::check_out(reservation.id, &mut conn)
#[tokio::test(flavor = "multi_thread")]
async fn test_check_out(setup: &Setup) {
let mut conn = setup.pool.get().await.unwrap();
Reservation::check_out(setup.reservation.id, &mut conn)
.await
.unwrap();
assert!(
!Reservation::read(reservation.id, &mut conn)
!Reservation::read(setup.reservation.id, &mut conn)
.await
.unwrap()
.checked_in
@ -190,40 +184,34 @@ mod tests {
Duration::days(11),
Err(ReservationError::RoomNotAvailable)
)]
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
async fn test_room_available(
#[future] setup: Setup,
setup: &Setup,
#[case] start: Duration,
#[case] end: Duration,
#[case] expected: Result<(), ReservationError>,
) {
let Setup {
mut conn,
reservation,
..
} = setup.await;
let mut conn = setup.pool.get().await.unwrap();
let now = Utc::now().naive_utc();
let start = now + start;
let end = now + end;
assert_eq!(
Reservation::room_available(reservation.room_id, (start, end).into(), &mut conn).await,
Reservation::room_available(setup.reservation.room_id, (start, end).into(), &mut conn)
.await,
expected
);
}
#[rstest]
#[tokio::test]
async fn test_room_available_no_reservations(#[future] setup: Setup) {
let Setup {
mut conn, hotel, ..
} = setup.await;
#[tokio::test(flavor = "multi_thread")]
async fn test_room_available_no_reservations(setup: &Setup) {
let mut conn = setup.pool.get().await.unwrap();
let room = Room::insert(
Room {
id: 2,
hotel_id: hotel.id,
hotel_id: setup.hotel.id,
beds: 1,
size: 1,
},
@ -243,21 +231,30 @@ mod tests {
}
#[fixture]
async fn setup() -> Setup {
let mut conn = setup_test_transaction().await.unwrap();
let hotel = insert_hotel(&mut conn).await;
let user = insert_user(&mut conn).await;
let room = insert_room(&mut conn, hotel.id).await;
let reservation = insert_reservation(&mut conn, room.id, user.email).await;
Setup {
conn,
hotel,
reservation,
}
#[once]
fn setup() -> Setup {
block_on(async {
let test_container = create_test_containers_pool().await.unwrap();
let pool = test_container.pool;
let mut conn = pool.get().await.unwrap();
let hotel = insert_hotel(&mut conn).await;
let user = insert_user(&mut conn).await;
let room = insert_room(&mut conn, hotel.id).await;
let reservation = insert_reservation(&mut conn, room.id, user.email).await;
Setup {
_container: test_container.container,
pool,
hotel,
reservation,
}
})
}
struct Setup {
conn: AsyncPgConnection,
_container: ContainerAsync<Postgres>,
pool: PgPool,
hotel: Hotel,
reservation: Reservation,
}

View File

@ -55,13 +55,14 @@ impl Room {
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
use crate::models::hotel::{CreateHotel, Hotel};
use crate::models::reservation::{CreateReservation, Reservation};
use crate::models::room::Room;
use crate::models::user::{CreateUser, User};
use crate::test::setup_test_transaction;
use diesel_async::AsyncPgConnection;
use lib::diesel_crud_trait::DieselCrudCreate;
use lib::test::diesel_pool::setup_test_transaction;
use serde_json::json;
#[rstest]
@ -148,7 +149,7 @@ mod tests {
#[fixture]
async fn setup() -> Setup {
let mut conn = setup_test_transaction().await.unwrap();
let mut conn = setup_test_transaction(config::DATABASE_URL).await.unwrap();
let hotels = insert_hotels(&mut conn).await;
let rooms = insert_rooms(&mut conn, &hotels).await;
let _reservation = insert_reservation(&mut conn, rooms[0].id).await;

View File

@ -1,78 +1,11 @@
use crate::config;
use crate::database::{create_pool, create_pool_from_url, GetConnection, PgPool};
use crate::error::AppError;
use axum::async_trait;
use axum::body::{to_bytes, Body};
use axum::http::header::CONTENT_TYPE;
use axum::http::Request;
use axum::response::Response;
use deadpool_diesel::postgres::BuildError;
use deadpool_diesel::Status;
use derive_more::Constructor;
use diesel_async::pooled_connection::deadpool::{Object, PoolError};
use diesel_async::{AsyncConnection, AsyncPgConnection};
use mime::APPLICATION_JSON;
use serde::{Deserialize, Serialize};
use serde_json::json;
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::runners::AsyncRunner;
use testcontainers_modules::testcontainers::{ContainerAsync, TestcontainersError};
use thiserror::Error;
#[derive(Debug, PartialEq, Error)]
pub enum Error {
#[error(transparent)]
Connection(#[from] diesel::ConnectionError),
#[error(transparent)]
Database(#[from] diesel::result::Error),
}
pub async fn setup_test_transaction() -> Result<AsyncPgConnection, Error> {
let mut conn = AsyncPgConnection::establish(config::DATABASE_URL).await?;
conn.begin_test_transaction().await?;
Ok(conn)
}
pub(crate) async fn create_test_pool() -> Result<PoolStub, BuildError> {
let pool = create_pool()?;
Ok(PoolStub(pool))
}
#[derive(Debug, Error)]
pub(crate) enum ContainerError {
#[error(transparent)]
TestContainers(#[from] TestcontainersError),
#[error(transparent)]
BuildError(#[from] BuildError),
#[error(transparent)]
PoolError(#[from] PoolError),
#[error(transparent)]
DieselError(#[from] diesel::result::Error),
}
/// When the TestContainer is dropped, the container will be removed.
/// # Panics
/// If destructed and the container field is dropped, the container will be removed, and using the pool will cause panic.
#[derive(Constructor)]
pub(crate) struct TestContainer {
pub _container: ContainerAsync<Postgres>,
pub pool: PgPool,
}
use diesel_async::AsyncPgConnection;
use lib::test::test_containers::{ContainerError, TestContainer};
pub(crate) async fn create_test_containers_pool<'a>() -> Result<TestContainer, ContainerError> {
let container = create_postgres_container().await?;
let connection_string = format!(
"postgres://postgres:postgres@127.0.0.1:{}/postgres",
container.get_host_port_ipv4(5432).await?
);
let pool = create_pool_from_url(connection_string)?;
run_migrations(pool.get().await?.as_mut()).await?;
Ok(TestContainer::new(container, pool))
}
pub(crate) async fn create_postgres_container(
) -> Result<ContainerAsync<Postgres>, TestcontainersError> {
Postgres::default().start().await
let test_container = lib::test::test_containers::create_test_containers_pool().await?;
run_migrations(test_container.pool.get().await?.as_mut()).await?;
Ok(test_container)
}
pub(crate) async fn run_migrations(
@ -80,50 +13,3 @@ pub(crate) async fn run_migrations(
) -> Result<(), diesel::result::Error> {
config::MIGRATIONS.run_pending_migrations(conn).await
}
#[derive(Clone)]
pub(crate) struct PoolStub(PgPool);
#[async_trait]
impl GetConnection for PoolStub {
async fn get(&self) -> Result<Object<AsyncPgConnection>, AppError> {
let mut conn = self.0.get().await?;
conn.begin_test_transaction().await?;
Ok(conn)
}
fn status(&self) -> Status {
unimplemented!("PoolStub does not support status")
}
}
pub trait BuildJson {
fn json<T: Serialize>(self, body: T) -> Result<Request<Body>, axum::http::Error>;
}
impl BuildJson for axum::http::request::Builder {
fn json<T: Serialize>(self, body: T) -> Result<Request<Body>, axum::http::Error> {
self.header(CONTENT_TYPE, APPLICATION_JSON.as_ref())
.body(Body::new(json!(body).to_string()))
}
}
#[derive(Debug, Error)]
pub(crate) enum DeserializeError {
#[error(transparent)]
SerdeError(#[from] serde_json::Error),
#[error(transparent)]
AxumError(#[from] axum::Error),
}
#[async_trait]
pub trait DeserializeInto {
async fn deserialize_into<T: for<'de> Deserialize<'de>>(self) -> Result<T, DeserializeError>;
}
#[async_trait]
impl DeserializeInto for Response {
async fn deserialize_into<T: for<'de> Deserialize<'de>>(self) -> Result<T, DeserializeError> {
let body = to_bytes(self.into_body(), usize::MAX).await?;
serde_json::from_slice(&body).map_err(Into::into)
}
}