diff --git a/src/bidding/confirm_bid_amount_callback.rs b/src/bidding/confirm_bid_amount_callback.rs index 99de8d7..9a7adb9 100644 --- a/src/bidding/confirm_bid_amount_callback.rs +++ b/src/bidding/confirm_bid_amount_callback.rs @@ -154,7 +154,7 @@ mod tests { listing: PersistedListing, } async fn set_up_fixtures() -> Fixtures { - let deps = create_deps().await; + let Deps { deps, .. } = create_deps().await; let seller = with_test_user(&deps, |seller| { seller.username = Some("seller".to_string()); seller.telegram_id = 123.into() diff --git a/src/bidding/mod.rs b/src/bidding/mod.rs index cde0656..d637ca7 100644 --- a/src/bidding/mod.rs +++ b/src/bidding/mod.rs @@ -168,7 +168,7 @@ async fn handle_view_listing_bids( response_lines.push(format!( "💰 Current highest bid: {current_bid} from {buyer_name}", current_bid = current_bid.bid_amount.with_type(currency_type), - buyer_name = user_name_or_link(&buyer) + buyer_name = user_name_or_link(buyer) )); } else { response_lines.push("💰 No bids yet".to_string()); @@ -181,7 +181,7 @@ async fn handle_view_listing_bids( response_lines.push(format!( "💰 Bid: {bid_amount} from {buyer_name}", bid_amount = bid.bid_amount.with_type(currency_type), - buyer_name = user_name_or_link(&bidder) + buyer_name = user_name_or_link(bidder) )); } diff --git a/src/bot_message_sender.rs b/src/bot_message_sender.rs index 6e2453f..98b74fc 100644 --- a/src/bot_message_sender.rs +++ b/src/bot_message_sender.rs @@ -16,9 +16,9 @@ use crate::{ BotError, BotResult, MessageTarget, }; -pub struct BotMessageSender(Bot, MessageTarget); +pub struct BotMessageSender(Bot, Option); impl BotMessageSender { - pub fn new(bot: Bot, message_target: MessageTarget) -> Self { + pub fn new(bot: Bot, message_target: Option) -> Self { Self(bot, message_target) } } @@ -26,7 +26,7 @@ impl BotMessageSender { #[async_trait] impl MessageSender for BotMessageSender { fn with_target(&self, target: MessageTarget) -> BoxedMessageSender { - let clone = Self(self.0.clone(), target); + let clone = Self(self.0.clone(), Some(target)); Box::new(clone) } async fn send_html_message( @@ -34,7 +34,10 @@ impl MessageSender for BotMessageSender { text: String, keyboard: Option, ) -> BotResult { - let target = self.1.clone(); + let target = self + .1 + .clone() + .ok_or(BotError::internal_str("No message target"))?; if let Some(message_id) = target.message_id { log::info!("Editing message in chat: {target:?}"); let mut message = self @@ -107,6 +110,36 @@ impl MessageSender for BotMessageSender { self.send_bid_invalid_listing_expired(listing, buyer) .await?; } + MessageType::UserHasWonListingForSeller { + listing, + buyer, + seller, + bid, + } => { + self.send_user_has_won_listing_for_seller(listing, buyer, seller, bid) + .await?; + } + MessageType::UserHasWonListingForBuyer { + listing, + buyer, + seller, + bid, + } => { + self.send_user_has_won_listing_for_buyer(listing, buyer, seller, bid) + .await?; + } + MessageType::UserHasLostListingForBuyer { + listing, + buyer, + seller, + } => { + self.send_user_has_lost_listing_for_buyer(listing, buyer, seller) + .await?; + } + MessageType::ListingExpiredWithNoWinnerForSeller { listing, seller } => { + self.send_listing_expired_with_no_winner_for_seller(listing, seller) + .await?; + } } Ok(()) } @@ -186,4 +219,78 @@ impl BotMessageSender { ) .await } + + async fn send_user_has_won_listing_for_seller( + &self, + listing: PersistedListing, + buyer: PersistedUser, + seller: PersistedUser, + bid: PersistedBid, + ) -> BotResult { + self.with_target(seller.into()) + .send_html_message( + format!( + "{buyer} has won {title} for {bid_amount}", + buyer = user_name_or_link(&buyer), + title = listing.base.title, + bid_amount = bid.bid_amount.with_type(listing.base.currency_type), + ), + None, + ) + .await + } + + async fn send_user_has_won_listing_for_buyer( + &self, + listing: PersistedListing, + buyer: PersistedUser, + seller: PersistedUser, + bid: PersistedBid, + ) -> BotResult { + self.with_target(buyer.into()) + .send_html_message( + format!( + "You have won {title} by {seller} for {bid_amount}", + title = listing.base.title, + seller = user_name_or_link(&seller), + bid_amount = bid.bid_amount.with_type(listing.base.currency_type), + ), + None, + ) + .await + } + + async fn send_user_has_lost_listing_for_buyer( + &self, + listing: PersistedListing, + buyer: PersistedUser, + seller: PersistedUser, + ) -> BotResult { + self.with_target(buyer.into()) + .send_html_message( + format!( + "Auction {title} by {seller} has ended, and you were not the winner", + title = listing.base.title, + seller = user_name_or_link(&seller), + ), + None, + ) + .await + } + + async fn send_listing_expired_with_no_winner_for_seller( + &self, + listing: PersistedListing, + seller: PersistedUser, + ) -> BotResult { + self.with_target(seller.into()) + .send_html_message( + format!( + "Auction {title} has ended with no winner", + title = listing.base.title + ), + None, + ) + .await + } } diff --git a/src/db/dao/listing_dao.rs b/src/db/dao/listing_dao.rs index 185f067..6891485 100644 --- a/src/db/dao/listing_dao.rs +++ b/src/db/dao/listing_dao.rs @@ -62,7 +62,7 @@ impl ListingDAO { pub async fn insert_listing(&self, listing: &NewListing) -> Result { let now = Utc::now(); - let binds = binds_for_listing(&listing) + let binds = binds_for_listing(listing) .push("seller_id", &listing.base.seller_id) .push("starts_at", &listing.base.starts_at) .push("ends_at", &listing.base.ends_at) @@ -89,7 +89,7 @@ impl ListingDAO { pub async fn update_listing(&self, listing: &PersistedListing) -> Result { let now = Utc::now(); - let binds = binds_for_listing(&listing).push("updated_at", &now); + let binds = binds_for_listing(listing).push("updated_at", &now); let query_str = format!( r#" diff --git a/src/listing_expiry_checker_task.rs b/src/listing_expiry_checker_task.rs index 5ad3ef5..26fdee2 100644 --- a/src/listing_expiry_checker_task.rs +++ b/src/listing_expiry_checker_task.rs @@ -5,9 +5,13 @@ use crate::{ *, }; -pub async fn task(daos: DAOs, mut listing_event: Receiver) -> BotResult<()> { +pub async fn task( + message_sender: BoxedMessageSender, + daos: DAOs, + mut listing_event: Receiver, +) -> BotResult<()> { loop { - match task_impl(daos.clone(), &mut listing_event).await { + match task_impl(&message_sender, &daos, &mut listing_event).await { Ok(r) => return Ok(r), Err(e) => { log::error!("Error in listing expiry checker task: {e}"); @@ -17,13 +21,17 @@ pub async fn task(daos: DAOs, mut listing_event: Receiver) } } -async fn task_impl(daos: DAOs, listing_event: &mut Receiver) -> BotResult<()> { +async fn task_impl( + message_sender: &BoxedMessageSender, + daos: &DAOs, + listing_event: &mut Receiver, +) -> BotResult<()> { const MAX_CHECK_INTERVAL: Duration = Duration::seconds(10); loop { let check_again_in = if let Some(listing) = daos.listing.find_next_ending_listing().await? { - if handle_listing_expiry(&daos, &listing).await? { + if handle_listing_expiry(message_sender, daos, &listing).await? { // if this listing was expired, immediately check again on the next listing continue; } @@ -48,17 +56,184 @@ async fn task_impl(daos: DAOs, listing_event: &mut Receiver } } -async fn handle_listing_expiry(daos: &DAOs, listing: &PersistedListing) -> BotResult { +async fn handle_listing_expiry( + message_sender: &BoxedMessageSender, + daos: &DAOs, + listing: &PersistedListing, +) -> BotResult { if listing.base.ends_at < Utc::now() { let expired_ago = Utc::now() - listing.base.ends_at; log::info!( "listing expired: {listing_id} (expired {expired_ago} ago)", listing_id = listing.persisted.id, ); - daos.listing + let listing = daos + .listing .set_listing_is_active(listing.persisted.id, false) .await?; + + let seller = daos + .user + .find_by_id(listing.base.seller_id) + .await? + .ok_or(BotError::internal_str("Seller not found"))?; + + let bids = daos.bid.bids_for_listing(listing.persisted.id).await?; + + if let Some(bid) = bids.first() { + let buyer = daos + .user + .find_by_id(bid.buyer_id) + .await? + .ok_or(BotError::internal_str("Winning buyer not found"))?; + + message_sender + .send_message(MessageType::UserHasWonListingForBuyer { + listing: listing.clone(), + buyer: buyer.clone(), + seller: seller.clone(), + bid: bid.clone(), + }) + .await?; + + message_sender + .send_message(MessageType::UserHasWonListingForSeller { + listing: listing.clone(), + buyer: buyer.clone(), + seller: seller.clone(), + bid: bid.clone(), + }) + .await?; + + // send message to all other buyers who did not win + for bid in bids.iter() { + if bid.buyer_id == buyer.persisted.id { + continue; + } + let buyer = daos + .user + .find_by_id(bid.buyer_id) + .await? + .ok_or(BotError::internal_str("Losing buyer not found"))?; + message_sender + .send_message(MessageType::UserHasLostListingForBuyer { + listing: listing.clone(), + buyer: buyer.clone(), + seller: seller.clone(), + }) + .await?; + } + } else { + // there was no winner, send notification to the seller + message_sender + .send_message(MessageType::ListingExpiredWithNoWinnerForSeller { + listing: listing.clone(), + seller: seller.clone(), + }) + .await?; + } + return Ok(true); } Ok(false) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::bid::NewBid; + use crate::db::MoneyAmount; + use crate::message_sender::MockMessageSender; + use crate::test_utils::*; + use mockall::predicate::function; + use std::str::FromStr; + + #[tokio::test] + async fn test_listing_expiry() { + let Deps { deps, .. } = create_deps().await; + let seller = with_test_user(&deps, |user| { + user.telegram_id = 123.into(); + user.username = Some("seller".to_string()) + }) + .await; + let losing_buyer = with_test_user(&deps, |user| { + user.telegram_id = 789.into(); + user.username = Some("losing_buyer".to_string()) + }) + .await; + let winning_buyer = with_test_user(&deps, |user| { + user.telegram_id = 456.into(); + user.username = Some("buyer".to_string()) + }) + .await; + let listing = with_test_listing(&deps, &seller, |listing| { + listing.base.ends_at = Utc::now() - Duration::seconds(1); + }) + .await; + + // the losing bid + deps.get::() + .bid + .insert_bid(&NewBid::new_basic( + listing.persisted.id, + losing_buyer.persisted.id, + MoneyAmount::from_str("50.00").unwrap(), + )) + .await + .unwrap(); + + // the winning bid + deps.get::() + .bid + .insert_bid(&NewBid::new_basic( + listing.persisted.id, + winning_buyer.persisted.id, + MoneyAmount::from_str("100.00").unwrap(), + )) + .await + .unwrap(); + + let winner_id = winning_buyer.persisted.id; + let loser_id = losing_buyer.persisted.id; + let mut message_sender = MockMessageSender::new(); + + message_sender + .expect_send_message() + .with(function(move |m| match m { + MessageType::UserHasWonListingForSeller { buyer, .. } => { + buyer.persisted.id == winner_id + } + _ => false, + })) + .times(1) + .returning(|_| Ok(())); + + message_sender + .expect_send_message() + .with(function(move |m| match m { + MessageType::UserHasWonListingForBuyer { buyer, .. } => { + buyer.persisted.id == winner_id + } + _ => false, + })) + .times(1) + .returning(|_| Ok(())); + + message_sender + .expect_send_message() + .with(function(move |m| match m { + MessageType::UserHasLostListingForBuyer { buyer, .. } => { + buyer.persisted.id == loser_id + } + _ => false, + })) + .times(1) + .returning(|_| Ok(())); + + let daos = (*deps.get::()).clone(); + let message_sender: BoxedMessageSender = Box::new(message_sender); + handle_listing_expiry(&message_sender, &daos, &listing) + .await + .unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index 1de72d3..06e0a51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -191,6 +191,7 @@ async fn main() -> Result<()> { let daos = DAOs::new(db_pool.clone(), listing_expiry_checker_tx); tokio::spawn(listing_expiry_checker_task::task( + Box::new(BotMessageSender::new(*bot.clone(), None)), daos.clone(), listing_expiry_checker_rx, )); @@ -203,7 +204,7 @@ async fn main() -> Result<()> { |bot: Box, update: Update, daos: DAOs, bot_username: BotUsername| { let target = update_into_message_target(update)?; Some(App::new( - Box::new(BotMessageSender::new(*bot, target)), + Box::new(BotMessageSender::new(*bot, Some(target))), daos.clone(), bot_username.0, )) @@ -278,7 +279,8 @@ mod tests { always(), ) .returning(|_, _| Ok(())); - let mut deps = with_message_sender(create_deps().await, message_sender).await; + let Deps { deps, .. } = create_deps().await; + let mut deps = with_message_sender(deps, message_sender).await; deps.insert(create_tele_update("/help")); let handler = main_handler(); dptree::type_check(handler.sig(), &deps, &[]); diff --git a/src/message/mod.rs b/src/message/mod.rs index 5100fef..1a19b49 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -20,4 +20,25 @@ pub enum MessageType { listing: PersistedListing, buyer: PersistedUser, }, + UserHasWonListingForSeller { + listing: PersistedListing, + seller: PersistedUser, + buyer: PersistedUser, + bid: PersistedBid, + }, + UserHasWonListingForBuyer { + listing: PersistedListing, + buyer: PersistedUser, + seller: PersistedUser, + bid: PersistedBid, + }, + UserHasLostListingForBuyer { + listing: PersistedListing, + buyer: PersistedUser, + seller: PersistedUser, + }, + ListingExpiredWithNoWinnerForSeller { + listing: PersistedListing, + seller: PersistedUser, + }, } diff --git a/src/test_utils.rs b/src/test_utils.rs index b8f4590..59aeeef 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -6,6 +6,7 @@ use sqlx::SqlitePool; use std::{ops::Deref, str::FromStr, sync::Arc}; use teloxide::dispatching::dialogue::serializer::Json; use teloxide::types::*; +use tokio::sync::mpsc::Receiver; use crate::{ db::{ @@ -15,7 +16,8 @@ use crate::{ NewListing, PersistedListing, }, user::{NewUser, PersistedUser}, - CurrencyType, DAOs, ListingDAO, ListingEventSender, MoneyAmount, UserDAO, + CurrencyType, DAOs, ListingDAO, ListingEventSender, ListingUpdatedEvent, MoneyAmount, + UserDAO, }, message_sender::MockMessageSender, sqlite_storage::SqliteStorage, @@ -202,7 +204,11 @@ pub fn create_tele_callback_query(callback_data: &str, from: User) -> CallbackQu } } -pub async fn create_deps() -> DependencyMap { +pub struct Deps { + pub deps: DependencyMap, + pub listing_event_receiver: Receiver, +} +pub async fn create_deps() -> Deps { let pool = create_test_pool().await; let dialog_storage = SqliteStorage::new(pool.clone(), Json).await.unwrap(); let (tx, rx) = ListingEventSender::channel(); @@ -216,7 +222,10 @@ pub async fn create_deps() -> DependencyMap { can_connect_to_business: true, has_main_web_app: true, }; - dptree::deps![dialog_storage, me, daos, rx] + Deps { + deps: dptree::deps![dialog_storage, me, daos], + listing_event_receiver: rx, + } } pub async fn with_dialogue(mut deps: DependencyMap, user: &PersistedUser) -> DependencyMap { @@ -240,19 +249,6 @@ pub async fn with_message_sender( deps } -pub async fn create_test_user(dao: &UserDAO) -> PersistedUser { - dao.insert_user(&NewUser { - persisted: (), - telegram_id: 12345.into(), - first_name: "Test User".to_string(), - last_name: None, - username: Some("testuser".to_string()), - is_banned: false, - }) - .await - .unwrap() -} - pub async fn with_test_user( deps: &DependencyMap, user_fn: impl FnOnce(&mut NewUser) -> (),