diff --git a/Cargo.lock b/Cargo.lock index 21d484d..fab5937 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,6 +560,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_filter" version = "0.1.3" @@ -847,6 +856,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.11.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -965,6 +993,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -976,6 +1005,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1011,9 +1056,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1667,6 +1714,7 @@ dependencies = [ "num", "paste", "regex", + "reqwest", "rstest", "rust_decimal", "seq-macro", @@ -2026,16 +2074,20 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", + "mime", "mime_guess", "native-tls", "percent-encoding", @@ -2783,6 +2835,27 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "take_mut" version = "0.2.2" @@ -3006,6 +3079,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -3421,6 +3504,17 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/Cargo.toml b/Cargo.toml index acfc036..237bfa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ dptree = "0.5.1" seq-macro = "0.3.6" base64 = "0.22.1" mockall = "0.13.1" +reqwest = "0.12.23" [dev-dependencies] rstest = "0.26.1" diff --git a/src/bidding/confirm_bid_amount_callback.rs b/src/bidding/confirm_bid_amount_callback.rs new file mode 100644 index 0000000..b0c12ad --- /dev/null +++ b/src/bidding/confirm_bid_amount_callback.rs @@ -0,0 +1,249 @@ +use crate::{ + bidding::BiddingState, + commands::new_listing::validations::{validate_price, SetFieldError}, + db::{bid::NewBid, listing::PersistedListing, user::PersistedUser, MoneyAmount}, + message::MessageType, + App, BotError, BotResult, RootDialogue, +}; +use anyhow::{anyhow, Context}; +use itertools::Itertools; +use log::{error, info}; +use teloxide::types::*; + +pub async fn handle_awaiting_bid_amount_input( + app: App, + listing: PersistedListing, + dialogue: RootDialogue, + msg: Message, +) -> BotResult { + // parse the bid amount into a MoneyAmount + let text = msg + .text() + .ok_or(BotError::user_visible("Please enter a valid bid amount"))?; + let bid_amount = match validate_price(text) { + Ok(bid_amount) => bid_amount, + Err(SetFieldError::ValidationFailed(e)) => { + return Err(BotError::user_visible(e)); + } + Err(other) => { + return Err(anyhow!("Error validating bid amount: {other:?}").into()); + } + }; + + let bid_amount_str = format!("{}", bid_amount.with_type(listing.base.currency_type)); + app.bot + .send_html_message( + format!("Confirm bid amount: {bid_amount_str} - this cannot be undone!"), + Some(InlineKeyboardMarkup::default().append_row([ + InlineKeyboardButton::callback( + format!("Confirm bid amount: {bid_amount_str}"), + "confirm_bid", + ), + InlineKeyboardButton::callback("Cancel", "cancel_bid"), + ])), + ) + .await?; + + dialogue + .update(BiddingState::AwaitingConfirmBidAmount( + listing.persisted.id, + bid_amount, + )) + .await + .context("failed to update dialogue")?; + + Ok(()) +} + +pub async fn handle_awaiting_confirm_bid_amount_callback( + app: App, + buyer: PersistedUser, + listing: PersistedListing, + bid_amount: MoneyAmount, + dialogue: RootDialogue, + callback_query: CallbackQuery, +) -> BotResult { + let callback_data = callback_query + .data + .as_deref() + .ok_or(BotError::user_visible("Missing data in callback query"))?; + + let bid_amount = match callback_data { + "confirm_bid" => bid_amount, + "cancel_bid" => { + dialogue.exit().await.context("failed to exit dialogue")?; + app.bot + .send_html_message("Bid cancelled".to_string(), None) + .await?; + return Ok(()); + } + _ => { + return Err(BotError::user_visible(format!( + "Invalid response {callback_data}" + ))) + } + }; + + let bid = NewBid::new_basic(listing.persisted.id, buyer.persisted.id, bid_amount); + let bid = app.daos.bid.insert_bid(&bid).await?; + + dialogue.exit().await.context("failed to exit dialogue")?; + + app.send_message(MessageType::BidHasBeenConfirmedForBuyer { + listing: listing.clone(), + bid: bid.clone(), + }) + .await?; + + let other_bidder_ids = app + .daos + .bid + .bidder_ids_for_listing(listing.persisted.id) + .await? + .into_iter() + .filter(|id| *id != buyer.persisted.id) + .unique(); + + for buyer in app.daos.user.where_in_ids(other_bidder_ids).await? { + info!("Sending outbid message to {buyer:?}"); + app.send_message(MessageType::UserHasBeenOutbidForBuyer { + listing: listing.clone(), + buyer, + }) + .await?; + } + + if let Some(seller) = app.daos.user.find_by_id(listing.base.seller_id).await? { + app.send_message(MessageType::BidHasBeenPlacedForSeller { + listing, + buyer, + seller, + bid, + }) + .await?; + } else { + error!("Seller not found for listing {listing:?}"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{db::DAOs, message_sender::MockMessageSender, test_utils::*}; + use dptree::{deps, di::Injectable}; + use mockall::predicate::function; + use std::str::FromStr; + + #[tokio::test] + async fn test_confirm_bid_amount() { + let deps = create_deps().await; + let seller = with_test_user(&deps, |seller| { + seller.username = Some("seller".to_string()); + seller.telegram_id = 123.into() + }) + .await; + let buyer = with_test_user(&deps, |buyer| { + buyer.username = Some("buyer".to_string()); + buyer.telegram_id = 456.into() + }) + .await; + let prev_buyer = with_test_user(&deps, |buyer| { + buyer.username = Some("prev_buyer".to_string()); + buyer.telegram_id = 789.into() + }) + .await; + let listing = with_test_listing(&deps, &seller, |_| {}).await; + + deps.get::() + .bid + .insert_bid(&NewBid::new_basic( + listing.persisted.id, + prev_buyer.persisted.id, + MoneyAmount::from_str("50.00").unwrap(), + )) + .await + .unwrap(); + + let mut message_sender = MockMessageSender::new(); + + { + let l = listing.clone(); + let b = buyer.clone(); + message_sender + .expect_send_message() + .once() + .with(function(move |m| match m { + MessageType::BidHasBeenConfirmedForBuyer { listing, bid, .. } => { + assert_eq!(listing, &l); + assert_eq!(bid.buyer_id, b.persisted.id); + assert_eq!(bid.bid_amount.cents(), 10_000); + assert_eq!(bid.listing_id, l.persisted.id); + true + } + _ => false, + })) + .returning(|_| Ok(())); + } + + { + let l = listing.clone(); + let b = buyer.clone(); + let s = seller.clone(); + message_sender + .expect_send_message() + .once() + .with(function(move |m| match m { + MessageType::BidHasBeenPlacedForSeller { + listing, + buyer, + seller, + bid, + } => { + assert_eq!(listing, &l); + assert_eq!(buyer, &b); + assert_eq!(seller, &s); + assert_eq!(bid.buyer_id, b.persisted.id); + assert_eq!(bid.bid_amount.cents(), 10_000); + assert_eq!(bid.listing_id, l.persisted.id); + true + } + _ => false, + })) + .returning(|_| Ok(())); + } + + { + let l = listing.clone(); + let pb = prev_buyer.clone(); + message_sender + .expect_send_message() + .once() + .with(function(move |m| match m { + MessageType::UserHasBeenOutbidForBuyer { listing, buyer } => { + assert_eq!(listing, &l); + assert_eq!(buyer, &pb); + true + } + _ => false, + })) + .returning(|_| Ok(())); + } + + let deps = with_message_sender(deps, message_sender).await; + let cb_query = create_tele_callback_query( + "confirm_bid", + create_tele_user(|user| user.id = buyer.telegram_id.into()), + ); + let mut deps = with_dialogue(deps, &buyer).await; + deps.insert_container(deps![ + listing, + cb_query, + buyer, + MoneyAmount::from_str("100.00").unwrap() + ]); + let ret = handle_awaiting_confirm_bid_amount_callback.inject(&deps)().await; + assert!(ret.is_ok(), "{ret:?}"); + } +} diff --git a/src/bidding/mod.rs b/src/bidding/mod.rs index 6caa3e4..8d98a92 100644 --- a/src/bidding/mod.rs +++ b/src/bidding/mod.rs @@ -1,26 +1,30 @@ +mod confirm_bid_amount_callback; mod keyboards; use crate::{ + bidding::confirm_bid_amount_callback::{ + handle_awaiting_bid_amount_input, handle_awaiting_confirm_bid_amount_callback, + }, case, - commands::new_listing::validations::{validate_price, SetFieldError}, db::{ - bid::NewBid, listing::{ListingFields, PersistedListing}, user::PersistedUser, - DbListingId, MoneyAmount, UserDAO, + DbListingId, DbUserId, MoneyAmount, UserDAO, }, dptree_utils::MapTwo, handle_error::with_error_handler, handler_utils::find_listing_by_id, + message_utils::buyer_name_or_link, start_command_data::StartCommandData, App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue, }; -use anyhow::{anyhow, Context}; +use anyhow::Context; use log::info; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use teloxide::{ dispatching::UpdateFilterExt, - types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update}, + types::{InlineKeyboardButton, InlineKeyboardMarkup, Update}, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -46,6 +50,13 @@ pub fn bidding_handler() -> BotHandler { .endpoint(with_error_handler(handle_place_bid_on_listing)), ), ) + .branch( + Update::filter_callback_query() + .filter_map(StartCommandData::get_from_callback_query) + .filter_map(StartCommandData::get_view_listing_bids_start_command) + .filter_map_async(find_listing_by_id) + .endpoint(with_error_handler(handle_view_listing_bids)), + ) .branch( Update::filter_message() .chain(case![DialogueRootState::Bidding( @@ -84,7 +95,7 @@ async fn handle_place_bid_on_listing( .await? .ok_or(BotError::UserVisibleError("Seller not found".to_string()))?; - let fields = match &listing.fields { + let basic_auction_fields = match &listing.fields { ListingFields::BasicAuction(fields) => fields, _ => { return Err(BotError::UserVisibleError( @@ -100,15 +111,16 @@ async fn handle_place_bid_on_listing( let mut response_lines = vec![]; response_lines.push(format!( - "Place bid on listing for listing {}, ran by {}", - listing.base.title, - seller - .username - .clone() - .unwrap_or_else(|| seller.telegram_id.to_string()) + "Placing a bid {title}, ran by {seller}", + title = listing.base.title, + seller = buyer_name_or_link(&seller) )); + let currency_type = listing.base.currency_type; response_lines.push(format!("You are bidding on this listing as: {user:?}")); - response_lines.push(format!("Minimum bid: {}", fields.min_increment)); + response_lines.push(format!( + "Minimum bid: {}", + basic_auction_fields.min_increment.with_type(currency_type) + )); let keyboard = InlineKeyboardMarkup::default() .append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]); @@ -120,121 +132,58 @@ async fn handle_place_bid_on_listing( Ok(()) } -async fn handle_awaiting_bid_amount_input( - app: App, - listing: PersistedListing, - dialogue: RootDialogue, - msg: Message, -) -> BotResult { - // parse the bid amount into a MoneyAmount - let text = msg - .text() - .ok_or(BotError::user_visible("Please enter a valid bid amount"))?; - let bid_amount = match validate_price(text) { - Ok(bid_amount) => bid_amount, - Err(SetFieldError::ValidationFailed(e)) => { - return Err(BotError::user_visible(e)); - } - Err(other) => { - return Err(anyhow!("Error validating bid amount: {other:?}").into()); - } - }; - - let bid_amount_str = format!("{}{}", listing.base.currency_type.symbol(), bid_amount); - app.bot - .send_html_message( - format!("Confirm bid amount: {bid_amount_str} - this cannot be undone!"), - Some(InlineKeyboardMarkup::default().append_row([ - InlineKeyboardButton::callback( - format!("Confirm bid amount: {bid_amount_str}"), - "confirm_bid", - ), - InlineKeyboardButton::callback("Cancel", "cancel_bid"), - ])), - ) - .await?; - - dialogue - .update(BiddingState::AwaitingConfirmBidAmount( - listing.persisted.id, - bid_amount, - )) - .await - .context("failed to update dialogue")?; - - Ok(()) -} - -async fn handle_awaiting_confirm_bid_amount_callback( +async fn handle_view_listing_bids( app: App, listing: PersistedListing, user: PersistedUser, - bid_amount: MoneyAmount, - dialogue: RootDialogue, - callback_query: CallbackQuery, ) -> BotResult { - let callback_data = callback_query - .data - .as_deref() - .ok_or(BotError::user_visible("Missing data in callback query"))?; + if listing.base.seller_id != user.persisted.id { + return Err(BotError::user_visible( + "You are not the seller of this listing", + )); + } + let currency_type = listing.base.currency_type; - let bid_amount = match callback_data { - "confirm_bid" => bid_amount, - "cancel_bid" => { - dialogue.exit().await.context("failed to exit dialogue")?; - app.bot - .send_html_message("Bid cancelled".to_string(), None) - .await?; - return Ok(()); - } - _ => { - return Err(BotError::user_visible(format!( - "Invalid response {callback_data}" - ))) - } - }; + let bids = app.daos.bid.bids_for_listing(listing.persisted.id).await?; - let bid = NewBid::new_basic(listing.persisted.id, user.persisted.id, bid_amount); - app.daos.bid.insert_bid(bid).await?; + let mut response_lines = vec![]; + response_lines.push(format!( + "🔍 Bids on {title}", + title = listing.base.title + )); - dialogue.exit().await.context("failed to exit dialogue")?; - - let bid_amount_str = format!("{}{}", listing.base.currency_type.symbol(), bid_amount); - app.bot - .with_target(callback_query.from.into()) - .send_html_message( - format!("Bid placed for {bid_amount_str} on {}", listing.base.title), - None, - ) - .await?; - - let other_bidder_ids = app + let bidding_users = app .daos - .bid - .bids_for_listing(listing.persisted.id) + .user + .where_in_ids(bids.iter().map(|bid| bid.buyer_id)) .await? .into_iter() - .map(|bid| bid.buyer_id) - .filter(|id| *id != user.persisted.id); + .map(|bid| (bid.persisted.id, bid)) + .collect::>(); - let other_bidders = app.daos.user.where_in_ids(other_bidder_ids).await?; - for bidder in other_bidders { - app.bot - .with_target(bidder.into()) - .send_html_message( - format!( - "You have been outbid for {bid_amount_str} on {}", - listing.base.title - ), - None, - ) - .await?; + if let Some(current_bid) = bids.first() { + let buyer = bidding_users + .get(¤t_bid.buyer_id) + .ok_or(BotError::internal("Buyer not found"))?; + response_lines.push(format!( + "💰 Current highest bid: {current_bid} from {buyer_name}", + current_bid = current_bid.bid_amount.with_type(currency_type), + buyer_name = buyer_name_or_link(&buyer) + )); + } else { + response_lines.push("💰 No bids yet".to_string()); } - // TODO - keyboard with buttons to: - // - be notified if they are outbid - // - be notified when the auction ends - // - view details about the auction + for bid in bids.iter() { + let bidder = bidding_users + .get(&bid.buyer_id) + .ok_or(BotError::internal("Bidder not found"))?; + response_lines.push(format!( + "💰 Bid: {bid_amount} from {buyer_name}", + bid_amount = bid.bid_amount.with_type(currency_type), + buyer_name = buyer_name_or_link(&bidder) + )); + } Ok(()) } diff --git a/src/bot_message_sender.rs b/src/bot_message_sender.rs new file mode 100644 index 0000000..64fc458 --- /dev/null +++ b/src/bot_message_sender.rs @@ -0,0 +1,168 @@ +use anyhow::Context; +use async_trait::async_trait; +use teloxide::{ + payloads::{EditMessageTextSetters, SendMessageSetters}, + prelude::Requester, + types::*, + Bot, +}; + +use crate::{ + db::{bid::PersistedBid, listing::PersistedListing, user::PersistedUser}, + message::MessageType, + message_sender::{BoxedMessageSender, MessageSender}, + message_utils::buyer_name_or_link, + start_command_data::StartCommandData, + BotError, BotResult, MessageTarget, +}; + +pub struct BotMessageSender(Bot, MessageTarget); +impl BotMessageSender { + pub fn new(bot: Bot, message_target: MessageTarget) -> Self { + Self(bot, message_target) + } +} + +#[async_trait] +impl MessageSender for BotMessageSender { + fn with_target(&self, target: MessageTarget) -> BoxedMessageSender { + let clone = Self(self.0.clone(), target); + Box::new(clone) + } + async fn send_html_message( + &self, + text: String, + keyboard: Option, + ) -> BotResult { + let target = self.1.clone(); + if let Some(message_id) = target.message_id { + log::info!("Editing message in chat: {target:?}"); + let mut message = self + .0 + .edit_message_text(target.chat_id, message_id, &text) + .parse_mode(ParseMode::Html); + if let Some(kb) = keyboard { + message = message.reply_markup(kb); + } + message.await.context("failed to edit message")?; + } else { + log::info!("Sending message to chat: {target:?}"); + let mut message = self + .0 + .send_message(target.chat_id, &text) + .parse_mode(ParseMode::Html); + if let Some(kb) = keyboard { + message = message.reply_markup(kb); + } + message.await.context("failed to send message")?; + } + Ok(()) + } + + async fn answer_inline_query( + &self, + inline_query_id: InlineQueryId, + results: Vec, + ) -> BotResult { + self.0 + .answer_inline_query(inline_query_id, results) + .await + .map(|_| ()) + .map_err(|err| BotError::InternalError(err.into())) + } + + async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult { + self.0 + .answer_callback_query(query_id) + .await + .map(|_| ()) + .map_err(|err| BotError::InternalError(err.into())) + } + + async fn get_me(&self) -> BotResult { + self.0 + .get_me() + .await + .map_err(|err| BotError::InternalError(err.into())) + } + + async fn send_message(&self, message: MessageType) -> BotResult { + match message { + MessageType::UserHasBeenOutbidForBuyer { listing, buyer } => { + self.send_user_has_been_outbid(listing, buyer).await?; + } + MessageType::BidHasBeenPlacedForSeller { + listing, + buyer, + seller, + bid, + } => { + self.send_bid_has_been_placed(listing, buyer, seller, bid) + .await?; + } + MessageType::BidHasBeenConfirmedForBuyer { listing, bid } => { + self.send_bid_has_been_confirmed(listing, bid).await?; + } + } + Ok(()) + } +} + +impl BotMessageSender { + async fn send_user_has_been_outbid( + &self, + listing: PersistedListing, + buyer: PersistedUser, + ) -> BotResult { + self.with_target(buyer.into()) + .send_html_message( + format!( + "You have been outbid for {title}", + title = listing.base.title + ), + None, + ) + .await + } + + async fn send_bid_has_been_placed( + &self, + listing: PersistedListing, + buyer: PersistedUser, + seller: PersistedUser, + bid: PersistedBid, + ) -> BotResult { + self.with_target(seller.into()) + .send_html_message( + format!( + "A bid was placed on {title} for {bid_amount} by {buyer}", + bid_amount = bid.bid_amount.with_type(listing.base.currency_type), + title = listing.base.title, + buyer = buyer_name_or_link(&buyer), + ), + Some( + InlineKeyboardMarkup::default().append_row([InlineKeyboardButton::callback( + "View Details", + StartCommandData::ViewListingDetailsAsBuyer(listing.persisted.id), + )]), + ), + ) + .await + } + + async fn send_bid_has_been_confirmed( + &self, + listing: PersistedListing, + bid: PersistedBid, + ) -> BotResult { + self.send_html_message( + format!( + "Bid placed for {bid_amount} on {title}", + bid_amount = bid.bid_amount.with_type(listing.base.currency_type), + title = listing.base.title, + ), + None, + ) + .await + } +} diff --git a/src/bot_result.rs b/src/bot_result.rs index 3d58529..6ecd01e 100644 --- a/src/bot_result.rs +++ b/src/bot_result.rs @@ -11,6 +11,9 @@ impl BotError { pub fn user_visible(msg: impl Into) -> Self { Self::UserVisibleError(msg.into()) } + pub fn internal(msg: impl Into) -> Self { + Self::InternalError(anyhow::anyhow!(msg.into())) + } } pub type BotResult = Result; diff --git a/src/commands/my_listings/mod.rs b/src/commands/my_listings/mod.rs index 561e277..f5a324e 100644 --- a/src/commands/my_listings/mod.rs +++ b/src/commands/my_listings/mod.rs @@ -23,8 +23,7 @@ use crate::{ start_command_data::StartCommandData, App, BotError, BotResult, Command, DialogueRootState, RootDialogue, }; -use anyhow::{anyhow, Context}; -use base64::{prelude::BASE64_URL_SAFE, Engine}; +use anyhow::Context; use log::info; use serde::{Deserialize, Serialize}; use teloxide::{ @@ -58,7 +57,7 @@ pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription .branch( Update::filter_message() .filter_map(StartCommandData::get_from_update) - .filter_map(StartCommandData::get_view_listing_details_start_command) + .filter_map(StartCommandData::get_view_listing_details_as_buyer_start_command) .filter_map_async(find_listing_by_id) .endpoint(with_error_handler(handle_view_listing_details)), ) @@ -116,31 +115,14 @@ async fn handle_forward_listing( ) -> BotResult { info!("Handling forward listing inline query for listing {listing:?}"); - let bot_username = match app - .bot - .get_me() - .await - .context("failed to get bot username")? - .username - .as_ref() - { - Some(username) => username.to_string(), - None => return Err(anyhow!("Bot username not found").into()), - }; - // Create inline keyboard with auction interaction buttons let keyboard = InlineKeyboardMarkup::default() .append_row([ InlineKeyboardButton::url( - "💰 Place Bid?", - format!( - "tg://resolve?domain={}&start={}", - bot_username, - BASE64_URL_SAFE - .encode(format!("place_bid_on_listing:{}", listing.persisted.id)) - ) - .parse() - .unwrap(), + "💰 Place Bid", + app.url_for_start_command(StartCommandData::PlaceBidOnListing( + listing.persisted.id, + )), ), InlineKeyboardButton::callback( "👀 Watch", @@ -149,13 +131,9 @@ async fn handle_forward_listing( ]) .append_row([InlineKeyboardButton::url( "🔗 View Full Details", - format!( - "tg://resolve?domain={}&start={}", - bot_username, - BASE64_URL_SAFE.encode(format!("view_listing_details:{}", listing.persisted.id)) - ) - .parse() - .unwrap(), + app.url_for_start_command(StartCommandData::ViewListingDetailsAsBuyer( + listing.persisted.id, + )), )]); // Get the current price based on listing type diff --git a/src/commands/new_listing/callbacks.rs b/src/commands/new_listing/callbacks.rs index 84e37b7..e21a874 100644 --- a/src/commands/new_listing/callbacks.rs +++ b/src/commands/new_listing/callbacks.rs @@ -3,6 +3,8 @@ //! This module handles all callback query processing for buttons //! in the new listing creation and editing workflows. +use std::str::FromStr; + use crate::{ commands::{ my_listings::enter_my_listings, diff --git a/src/commands/new_listing/handlers.rs b/src/commands/new_listing/handlers.rs index fcaf48b..e53f003 100644 --- a/src/commands/new_listing/handlers.rs +++ b/src/commands/new_listing/handlers.rs @@ -271,7 +271,7 @@ async fn save_listing(listing_dao: &ListingDAO, draft: ListingDraft) -> BotResul (listing, "Listing updated!") } else { let listing = listing_dao - .insert_listing(NewListing { + .insert_listing(&NewListing { persisted: (), base: draft.base, fields: draft.fields, diff --git a/src/commands/new_listing/tests.rs b/src/commands/new_listing/tests.rs index df01434..7b5da01 100644 --- a/src/commands/new_listing/tests.rs +++ b/src/commands/new_listing/tests.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use chrono::Duration; use crate::{ diff --git a/src/commands/new_listing/validations.rs b/src/commands/new_listing/validations.rs index ebf6213..70d52a2 100644 --- a/src/commands/new_listing/validations.rs +++ b/src/commands/new_listing/validations.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use chrono::{DateTime, Duration, Utc}; use crate::db::{CurrencyType, MoneyAmount}; diff --git a/src/db/dao/bid_dao.rs b/src/db/dao/bid_dao.rs index 55cad87..0f4edf3 100644 --- a/src/db/dao/bid_dao.rs +++ b/src/db/dao/bid_dao.rs @@ -1,7 +1,7 @@ use crate::db::{ bid::{NewBid, PersistedBid, PersistedBidFields}, bind_fields::BindFields, - DbListingId, + DbListingId, DbUserId, }; use anyhow::Result; use chrono::Utc; @@ -19,7 +19,7 @@ impl BidDAO { #[allow(unused)] impl BidDAO { - pub async fn insert_bid(&self, bid: NewBid) -> Result { + pub async fn insert_bid(&self, bid: &NewBid) -> Result { let now = Utc::now(); let binds = BindFields::default() .push("listing_id", &bid.listing_id) @@ -48,11 +48,22 @@ impl BidDAO { Ok(FromRow::from_row(&row)?) } + pub async fn bidder_ids_for_listing(&self, listing_id: DbListingId) -> Result> { + let rows = + sqlx::query_as::<_, (DbUserId,)>("SELECT buyer_id FROM bids WHERE listing_id = ?") + .bind(listing_id) + .fetch_all(&self.0) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) + } + pub async fn bids_for_listing(&self, listing_id: DbListingId) -> Result> { - let rows = sqlx::query_as("SELECT * FROM bids WHERE listing_id = ?") - .bind(listing_id) - .fetch_all(&self.0) - .await?; + let rows = sqlx::query_as::<_, PersistedBid>( + "SELECT * FROM bids WHERE listing_id = ? ORDER BY bid_amount DESC", + ) + .bind(listing_id) + .fetch_all(&self.0) + .await?; Ok(rows) } } @@ -84,6 +95,8 @@ impl FromRow<'_, SqliteRow> for PersistedBidFields { #[cfg(test)] mod tests { + use std::str::FromStr; + use super::*; use crate::db::{ listing::{BasicAuctionFields, ListingFields}, @@ -141,7 +154,7 @@ mod tests { }; let listing = listing_dao - .insert_listing(new_listing) + .insert_listing(&new_listing) .await .expect("Failed to insert test listing"); @@ -172,7 +185,7 @@ mod tests { // Insert bid let inserted_bid = bid_dao - .insert_bid(new_bid.clone()) + .insert_bid(&new_bid) .await .expect("Failed to insert bid"); diff --git a/src/db/dao/listing_dao.rs b/src/db/dao/listing_dao.rs index 5efacd1..c5ba09b 100644 --- a/src/db/dao/listing_dao.rs +++ b/src/db/dao/listing_dao.rs @@ -28,7 +28,7 @@ impl ListingDAO { } /// Insert a new listing into the database - pub async fn insert_listing(&self, listing: NewListing) -> Result { + pub async fn insert_listing(&self, listing: &NewListing) -> Result { let now = Utc::now(); let binds = binds_for_listing(&listing) diff --git a/src/db/dao/user_dao.rs b/src/db/dao/user_dao.rs index 5762958..c73e34e 100644 --- a/src/db/dao/user_dao.rs +++ b/src/db/dao/user_dao.rs @@ -156,9 +156,14 @@ impl UserDAO { ) -> Result> { let mut builder = sqlx::query_builder::QueryBuilder::new("SELECT * FROM users WHERE id IN ("); + let mut count = 0; for id in ids { + count += 1; builder.push_bind(id); } + if count == 0 { + return Ok(vec![]); + } builder.push(")"); let rows = builder.build().fetch_all(&self.0).await?; diff --git a/src/db/models/bid.rs b/src/db/models/bid.rs index 8aab090..9edc66c 100644 --- a/src/db/models/bid.rs +++ b/src/db/models/bid.rs @@ -6,7 +6,7 @@ pub type PersistedBid = Bid; #[allow(unused)] pub type NewBid = Bid<()>; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] #[allow(unused)] pub struct PersistedBidFields { pub id: DbBidId, @@ -15,7 +15,7 @@ pub struct PersistedBidFields { } /// Actual bids placed on listings -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] #[allow(unused)] pub struct Bid

{ pub persisted: P, diff --git a/src/db/models/listing.rs b/src/db/models/listing.rs index 7ce46c2..b0664d5 100644 --- a/src/db/models/listing.rs +++ b/src/db/models/listing.rs @@ -9,7 +9,7 @@ //! The main `Listing` enum ensures that only valid fields are accessible for each type. //! Database mapping is handled through `ListingRow` with conversion traits. -use crate::db::{CurrencyType, DbListingId, ListingType, MoneyAmount, DbUserId}; +use crate::db::{CurrencyType, DbListingId, DbUserId, ListingType, MoneyAmount}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fmt::Debug; @@ -127,6 +127,8 @@ impl From<&ListingFields> for ListingType { #[cfg(test)] mod tests { + use std::str::FromStr; + use super::*; use crate::db::{DbTelegramUserId, UserDAO}; use chrono::Duration; @@ -195,7 +197,7 @@ mod tests { // Insert using DAO let created_listing = listing_dao - .insert_listing(new_listing.clone()) + .insert_listing(&new_listing) .await .expect("Failed to insert listing"); diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 3359671..f08f200 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -8,7 +8,7 @@ pub type PersistedUser = DbUser; pub type NewUser = DbUser<()>; /// Core user information -#[derive(Clone, FromRow)] +#[derive(Clone, FromRow, PartialEq)] #[allow(unused)] pub struct DbUser { pub persisted: P, @@ -35,7 +35,7 @@ impl Debug for DbUser { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] #[allow(unused)] pub struct PersistedUserFields { pub id: DbUserId, diff --git a/src/db/types/mod.rs b/src/db/types/mod.rs index 2953ec9..32a250f 100644 --- a/src/db/types/mod.rs +++ b/src/db/types/mod.rs @@ -1,6 +1,7 @@ mod currency_type; mod db_id; mod listing_duration; +mod money; mod money_amount; // Re-export all types for easy access @@ -8,4 +9,5 @@ mod money_amount; pub use currency_type::*; pub use db_id::*; pub use listing_duration::*; +pub use money::*; pub use money_amount::*; diff --git a/src/db/types/money.rs b/src/db/types/money.rs new file mode 100644 index 0000000..2bd48df --- /dev/null +++ b/src/db/types/money.rs @@ -0,0 +1,43 @@ +use crate::db::{CurrencyType, MoneyAmount}; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Money(CurrencyType, MoneyAmount); +impl Money { + pub fn new(currency_type: CurrencyType, money_amount: MoneyAmount) -> Self { + Self(currency_type, money_amount) + } +} +impl PartialOrd for Money { + fn partial_cmp(&self, other: &Self) -> Option { + if self.0 != other.0 { + return None; + } + self.1.partial_cmp(&other.1) + } +} +impl Display for Money { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}{}", self.0.symbol(), self.1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case("$100.00", CurrencyType::Usd, 100_00)] + #[case("¥100.00", CurrencyType::Jpy, 100_00)] + fn test_money_display( + #[case] expected: &str, + #[case] currency_type: CurrencyType, + #[case] amount_cents: i64, + ) { + let amount = MoneyAmount::from_cents(amount_cents); + let money = Money::new(currency_type, amount); + assert_eq!(money.to_string(), expected); + } +} diff --git a/src/db/types/money_amount.rs b/src/db/types/money_amount.rs index 9a6772a..a332c4b 100644 --- a/src/db/types/money_amount.rs +++ b/src/db/types/money_amount.rs @@ -6,6 +6,8 @@ use sqlx::{ use std::ops::{Add, Sub}; use std::str::FromStr; +use crate::db::{CurrencyType, Money}; + /// Newtype wrapper for monetary amounts stored as integer cents /// Stores as INTEGER in SQLite for precise comparisons and simple arithmetic #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -23,12 +25,6 @@ impl MoneyAmount { Self(cents) } - /// Create a MoneyAmount from a string representation (e.g., "12.34") - pub fn from_str(s: &str) -> Result { - let decimal = Decimal::from_str(s)?; - Ok(Self::new(decimal)) - } - /// Create a zero MoneyAmount pub fn zero() -> Self { Self(0) @@ -43,6 +39,20 @@ impl MoneyAmount { pub fn to_decimal(self) -> Decimal { Decimal::new(self.0, 2) // 2 decimal places for cents } + + pub fn with_type(self, currency_type: CurrencyType) -> Money { + Money::new(currency_type, self) + } +} + +impl FromStr for MoneyAmount { + type Err = rust_decimal::Error; + + /// Create a MoneyAmount from a string representation (e.g., "12.34") + fn from_str(s: &str) -> Result { + let decimal = Decimal::from_str(s)?; + Ok(Self::new(decimal)) + } } impl Default for MoneyAmount { @@ -475,9 +485,8 @@ mod tests { setup_test_data_for_queries(&pool).await; let threshold_amount = MoneyAmount::from_str(threshold).unwrap(); - let query = format!( - "SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {operator} ?" - ); + let query = + format!("SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {operator} ?"); let count_row = sqlx::query(&query) .bind(threshold_amount) diff --git a/src/main.rs b/src/main.rs index fd65062..a3dff5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod bidding; +mod bot_message_sender; mod bot_result; mod commands; mod config; @@ -7,6 +8,7 @@ mod dptree_utils; mod handle_error; mod handler_utils; mod keyboard_utils; +mod message; mod message_sender; mod message_target; mod message_utils; @@ -19,6 +21,7 @@ mod wrap_endpoint; use std::sync::Arc; use crate::bidding::{bidding_handler, BiddingState}; +use crate::bot_message_sender::BotMessageSender; use crate::commands::{ my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState}, new_listing::{new_listing_handler, NewListingState}, @@ -26,9 +29,11 @@ use crate::commands::{ use crate::db::DAOs; use crate::handle_error::with_error_handler; use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target}; -use crate::message_sender::{BotMessageSender, BoxedMessageSender}; +use crate::message::MessageType; +use crate::message_sender::BoxedMessageSender; use crate::sqlite_storage::SqliteStorage; -use anyhow::Result; +use crate::start_command_data::StartCommandData; +use anyhow::{anyhow, Result}; pub use bot_result::*; use commands::*; use config::Config; @@ -43,17 +48,36 @@ pub use wrap_endpoint::*; pub struct App { pub bot: Arc, pub daos: DAOs, + pub bot_username: String, } impl App { - pub fn new(bot: BoxedMessageSender, daos: DAOs) -> Self { + pub fn new(bot: BoxedMessageSender, daos: DAOs, bot_username: String) -> Self { Self { bot: Arc::new(bot), daos, + bot_username, } } + + pub fn url_for_start_command(&self, command: StartCommandData) -> reqwest::Url { + format!( + "tg://resolve?domain={}&start={}", + self.bot_username, + command.encode_for_start_command() + ) + .parse() + .unwrap() + } + + pub async fn send_message(&self, message: MessageType) -> BotResult { + self.bot.send_message(message).await + } } +#[derive(Debug, Clone)] +struct BotUsername(String); + /// Set up the bot's command menu that appears when users tap the menu button async fn setup_bot_commands(bot: &Bot) -> Result<()> { info!("Setting up bot command menu..."); @@ -157,20 +181,34 @@ async fn main() -> Result<()> { setup_bot_commands(&bot).await?; let handler_with_deps = dptree::entry() - .filter_map(|bot: Box, update: Update, daos: DAOs| { - let target = update_into_message_target(update)?; - Some(App::new( - Box::new(BotMessageSender::new(*bot, target)), - daos.clone(), - )) - }) + .filter_map( + |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)), + daos.clone(), + bot_username.0, + )) + }, + ) .chain(main_handler()); let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?; let daos = DAOs::new(db_pool.clone()); + let bot_username = bot + .get_me() + .await? + .username + .as_ref() + .ok_or(anyhow!("Bot username not found"))? + .clone(); Dispatcher::builder(bot, handler_with_deps) - .dependencies(dptree::deps![dialog_storage, daos]) + .dependencies(dptree::deps![ + dialog_storage, + daos, + BotUsername(bot_username) + ]) .enable_ctrlc_handler() .worker_queue_size(1) .build() @@ -196,7 +234,7 @@ mod tests { use super::*; use crate::message_sender::MockMessageSender; - use crate::test_utils::{create_deps, create_tele_update}; + use crate::test_utils::*; #[tokio::test] async fn test_main_handler() { @@ -209,7 +247,7 @@ mod tests { always(), ) .returning(|_, _| Ok(())); - let mut deps = create_deps(message_sender).await; + let mut deps = with_message_sender(create_deps().await, 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 new file mode 100644 index 0000000..1f1ecea --- /dev/null +++ b/src/message/mod.rs @@ -0,0 +1,19 @@ +use crate::db::{bid::PersistedBid, listing::PersistedListing, user::PersistedUser}; + +#[derive(Debug, Clone, PartialEq)] +pub enum MessageType { + UserHasBeenOutbidForBuyer { + listing: PersistedListing, + buyer: PersistedUser, + }, + BidHasBeenPlacedForSeller { + listing: PersistedListing, + buyer: PersistedUser, + seller: PersistedUser, + bid: PersistedBid, + }, + BidHasBeenConfirmedForBuyer { + listing: PersistedListing, + bid: PersistedBid, + }, +} diff --git a/src/message_sender.rs b/src/message_sender.rs index 33d99e4..db1b4bc 100644 --- a/src/message_sender.rs +++ b/src/message_sender.rs @@ -1,13 +1,7 @@ -use crate::{BotError, BotResult, MessageTarget}; -use anyhow::Context; +use crate::{message::MessageType, BotResult, MessageTarget}; use async_trait::async_trait; -use teloxide::{ - payloads::{EditMessageTextSetters, SendMessageSetters}, - prelude::Requester, - types::{ - CallbackQueryId, InlineKeyboardMarkup, InlineQueryId, InlineQueryResult, Me, ParseMode, - }, - Bot, +use teloxide::types::{ + CallbackQueryId, InlineKeyboardMarkup, InlineQueryId, InlineQueryResult, Me, }; #[async_trait] @@ -25,6 +19,7 @@ pub trait MessageSender { ) -> BotResult; async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult; async fn get_me(&self) -> BotResult; + async fn send_message(&self, message: MessageType) -> BotResult; } pub type BoxedMessageSender = Box; @@ -50,76 +45,6 @@ mockall::mock! { ) -> BotResult; async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult; async fn get_me(&self) -> BotResult; - } -} - -pub struct BotMessageSender(Bot, MessageTarget); -impl BotMessageSender { - pub fn new(bot: Bot, message_target: MessageTarget) -> Self { - Self(bot, message_target) - } -} - -#[async_trait] -impl MessageSender for BotMessageSender { - fn with_target(&self, target: MessageTarget) -> BoxedMessageSender { - let clone = Self(self.0.clone(), target); - Box::new(clone) - } - async fn send_html_message( - &self, - text: String, - keyboard: Option, - ) -> BotResult { - let target = self.1.clone(); - if let Some(message_id) = target.message_id { - log::info!("Editing message in chat: {target:?}"); - let mut message = self - .0 - .edit_message_text(target.chat_id, message_id, &text) - .parse_mode(ParseMode::Html); - if let Some(kb) = keyboard { - message = message.reply_markup(kb); - } - message.await.context("failed to edit message")?; - } else { - log::info!("Sending message to chat: {target:?}"); - let mut message = self - .0 - .send_message(target.chat_id, &text) - .parse_mode(ParseMode::Html); - if let Some(kb) = keyboard { - message = message.reply_markup(kb); - } - message.await.context("failed to send message")?; - } - Ok(()) - } - - async fn answer_inline_query( - &self, - inline_query_id: InlineQueryId, - results: Vec, - ) -> BotResult { - self.0 - .answer_inline_query(inline_query_id, results) - .await - .map(|_| ()) - .map_err(|err| BotError::InternalError(err.into())) - } - - async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult { - self.0 - .answer_callback_query(query_id) - .await - .map(|_| ()) - .map_err(|err| BotError::InternalError(err.into())) - } - - async fn get_me(&self) -> BotResult { - self.0 - .get_me() - .await - .map_err(|err| BotError::InternalError(err.into())) + async fn send_message(&self, message: MessageType) -> BotResult; } } diff --git a/src/message_utils.rs b/src/message_utils.rs index 23f19c0..560e42f 100644 --- a/src/message_utils.rs +++ b/src/message_utils.rs @@ -1,4 +1,4 @@ -use crate::{message_sender::BoxedMessageSender, BotResult}; +use crate::{db::user::PersistedUser, message_sender::BoxedMessageSender, BotResult}; use anyhow::anyhow; use chrono::{DateTime, Utc}; use num::One; @@ -56,3 +56,17 @@ pub fn pluralize_with_count + Display + Copy>( pub fn format_datetime(dt: DateTime) -> String { dt.format("%b %d, %Y %H:%M UTC").to_string() } + +pub fn buyer_name_or_link(user: &PersistedUser) -> String { + let link = format!("https://t.me/{}", user.telegram_id); + let name = if let Some(last_name) = &user.last_name { + format!("{} {}", user.first_name, last_name) + } else { + user.first_name.clone() + }; + if let Some(username) = &user.username { + format!("@{username} ({name})") + } else { + format!("{name}") + } +} diff --git a/src/start_command_data.rs b/src/start_command_data.rs index 1b053fb..c370863 100644 --- a/src/start_command_data.rs +++ b/src/start_command_data.rs @@ -1,28 +1,88 @@ use base64::{prelude::BASE64_URL_SAFE, Engine}; -use log::info; -use teloxide::types::{MediaKind, MessageKind, UpdateKind}; +use teloxide::types::{CallbackQuery, MediaKind, MessageKind, UpdateKind}; use crate::db::DbListingId; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Copy)] pub enum StartCommandData { PlaceBidOnListing(DbListingId), - ViewListingDetails(DbListingId), + ViewListingDetailsAsBuyer(DbListingId), + ViewListingBids(DbListingId), } +const PLACE_BID_ON_LISTING: &str = "place_bid_on_listing"; +const VIEW_LISTING_DETAILS_AS_BUYER: &str = "view_listing_details_as_buyer"; +const VIEW_LISTING_BIDS: &str = "view_listing_bids"; + impl From for String { fn from(value: StartCommandData) -> Self { match value { StartCommandData::PlaceBidOnListing(listing_id) => { - format!("place_bid_on_listing:{listing_id}") + format!("{PLACE_BID_ON_LISTING}:{listing_id}") } - StartCommandData::ViewListingDetails(listing_id) => { - format!("view_listing_details:{listing_id}") + StartCommandData::ViewListingDetailsAsBuyer(listing_id) => { + format!("{VIEW_LISTING_DETAILS_AS_BUYER}:{listing_id}") + } + StartCommandData::ViewListingBids(listing_id) => { + format!("{VIEW_LISTING_BIDS}:{listing_id}") } } } } +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum StartCommandDataError { + MissingCommandName, + UnknownCommandName(String), + InvalidIdPart(String), + MissingIdPart, +} + +impl TryFrom for StartCommandData { + type Error = StartCommandDataError; + fn try_from(value: String) -> Result { + Self::try_from(value.as_str()) + } +} + +impl TryFrom<&String> for StartCommandData { + type Error = StartCommandDataError; + fn try_from(value: &String) -> Result { + Self::try_from(value.as_str()) + } +} + +impl TryFrom<&str> for StartCommandData { + type Error = StartCommandDataError; + fn try_from(value: &str) -> Result { + let mut parts = value.split(":").map(|s| s.trim()); + let name = parts + .next() + .ok_or(StartCommandDataError::MissingCommandName)?; + + let mut get_id_part = || -> Result { + let id_part = parts.next().ok_or(StartCommandDataError::MissingIdPart)?; + id_part.parse::().map_err(|_| { + if id_part.is_empty() { + StartCommandDataError::MissingIdPart + } else { + StartCommandDataError::InvalidIdPart(id_part.to_string()) + } + }) + }; + + Ok(match name { + "" => return Err(StartCommandDataError::MissingCommandName), + PLACE_BID_ON_LISTING => Self::PlaceBidOnListing(DbListingId::new(get_id_part()?)), + VIEW_LISTING_DETAILS_AS_BUYER => { + Self::ViewListingDetailsAsBuyer(DbListingId::new(get_id_part()?)) + } + VIEW_LISTING_BIDS => Self::ViewListingBids(DbListingId::new(get_id_part()?)), + _ => return Err(StartCommandDataError::UnknownCommandName(name.to_string())), + }) + } +} + impl StartCommandData { pub fn get_from_update(update: teloxide::types::Update) -> Option { let message = match update.kind { @@ -40,17 +100,17 @@ impl StartCommandData { let message = message.text.strip_prefix("/start ")?; let decoded = BASE64_URL_SAFE.decode(message).ok()?; let decoded = String::from_utf8(decoded).ok()?; - let parts = decoded.split(":").map(|s| s.trim()).collect::>(); - info!("command parts: {parts:?}"); - match parts.first()?.trim() { - "place_bid_on_listing" => Some(StartCommandData::PlaceBidOnListing(DbListingId::new( - parts.get(1)?.parse::().ok()?, - ))), - "view_listing_details" => Some(StartCommandData::ViewListingDetails(DbListingId::new( - parts.get(1)?.parse::().ok()?, - ))), - _ => None, - } + StartCommandData::try_from(decoded.as_str()).ok() + } + + pub fn get_from_callback_query(callback_query: CallbackQuery) -> Option { + let data = callback_query.data.as_ref()?; + StartCommandData::try_from(data.as_str()).ok() + } + + pub fn encode_for_start_command(self) -> String { + let as_string: String = self.into(); + BASE64_URL_SAFE.encode(as_string) } pub fn get_place_bid_on_listing_start_command( @@ -63,13 +123,73 @@ impl StartCommandData { } } - pub fn get_view_listing_details_start_command( + pub fn get_view_listing_details_as_buyer_start_command( command: StartCommandData, ) -> Option { - if let StartCommandData::ViewListingDetails(listing_id) = command { + if let StartCommandData::ViewListingDetailsAsBuyer(listing_id) = command { + Some(listing_id) + } else { + None + } + } + + pub fn get_view_listing_bids_start_command(command: StartCommandData) -> Option { + if let StartCommandData::ViewListingBids(listing_id) = command { Some(listing_id) } else { None } } } + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use crate::{ + db::DbListingId, + start_command_data::{StartCommandData, StartCommandDataError}, + test_utils::create_tele_update, + }; + + #[test] + fn test_get_from_update() { + let update = create_tele_update("/start cGxhY2VfYmlkX29uX2xpc3Rpbmc6Mg=="); + let command = StartCommandData::get_from_update(update); + assert_eq!( + command, + Some(StartCommandData::PlaceBidOnListing(DbListingId::new(2))) + ); + } + + #[test] + fn test_malformed_from_update() { + let update = create_tele_update("/start cGxhY2VfYmlkX29uX2xpc3Rpbmc6Mg"); + let command = StartCommandData::get_from_update(update); + assert_eq!(command, None); + } + + #[rstest] + #[case(StartCommandData::PlaceBidOnListing(DbListingId::new(1)))] + #[case(StartCommandData::ViewListingDetailsAsBuyer(DbListingId::new(2)))] + #[case(StartCommandData::ViewListingBids(DbListingId::new(3)))] + fn test_start_command_data(#[case] command: StartCommandData) { + let encoded: String = command.into(); + let decoded = StartCommandData::try_from(encoded).unwrap(); + assert_eq!(command, decoded); + } + + #[rstest] + #[case("", StartCommandDataError::MissingCommandName)] + #[case("malformed", StartCommandDataError::UnknownCommandName("malformed".to_string()))] + #[case("place_bid_on_listing", StartCommandDataError::MissingIdPart)] + #[case("place_bid_on_listing:", StartCommandDataError::MissingIdPart)] + #[case("place_bid_on_listing:abc", StartCommandDataError::InvalidIdPart("abc".to_string()))] + fn test_malformed_start_command_data( + #[case] encoded: &str, + #[case] expected_error: StartCommandDataError, + ) { + let decoded = StartCommandData::try_from(encoded); + assert_eq!(decoded, Err(expected_error)); + } +} diff --git a/src/test_utils.rs b/src/test_utils.rs index f427ab2..5e5a463 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1,12 +1,26 @@ //! Test utilities including timestamp comparison macros -use chrono::Utc; +use chrono::{Duration, Utc}; use dptree::di::DependencyMap; use sqlx::SqlitePool; +use std::{ops::Deref, str::FromStr, sync::Arc}; use teloxide::dispatching::dialogue::serializer::Json; use teloxide::types::*; -use crate::{db::DAOs, message_sender::MockMessageSender, sqlite_storage::SqliteStorage, App}; +use crate::{ + db::{ + listing::{ + BasicAuctionFields, ListingBase, + ListingFields::{self, BasicAuction}, + NewListing, PersistedListing, + }, + user::{NewUser, PersistedUser}, + CurrencyType, DAOs, ListingDAO, MoneyAmount, UserDAO, + }, + message_sender::MockMessageSender, + sqlite_storage::SqliteStorage, + App, DialogueRootState, RootDialogue, +}; /// Assert that two timestamps are approximately equal within a given epsilon tolerance. /// @@ -109,17 +123,19 @@ pub async fn create_test_pool() -> SqlitePool { pool } -pub fn create_tele_user(username: &str) -> User { - User { +pub fn create_tele_user(user_fn: impl FnOnce(&mut User) -> ()) -> User { + let mut user = User { id: UserId(1), - username: Some(username.to_string()), - first_name: username.to_string(), + username: Some("username".to_string()), + first_name: "firstname".to_string(), last_name: Some("lastname".to_string()), is_bot: false, language_code: Some("en".to_string()), is_premium: false, added_to_attachment_menu: false, - } + }; + user_fn(&mut user); + user } pub fn create_tele_private_chat(user: &User) -> Chat { @@ -134,7 +150,7 @@ pub fn create_tele_private_chat(user: &User) -> Chat { } pub fn create_tele_update(message_text: &str) -> Update { - let user = create_tele_user("sender"); + let user = create_tele_user(|user| user.username = Some("sender".to_string())); let chat = create_tele_private_chat(&user); Update { id: UpdateId(1), @@ -174,12 +190,23 @@ pub fn create_tele_update(message_text: &str) -> Update { } } -pub async fn create_deps(mock_bot: MockMessageSender) -> DependencyMap { +pub fn create_tele_callback_query(callback_data: &str, from: User) -> CallbackQuery { + CallbackQuery { + id: CallbackQueryId("query_id".to_string()), + data: Some(callback_data.to_string()), + from: from.clone(), + message: None, + chat_instance: "chat_instance".to_string(), + inline_message_id: None, + game_short_name: None, + } +} + +pub async fn create_deps() -> DependencyMap { let pool = create_test_pool().await; let dialog_storage = SqliteStorage::new(pool.clone(), Json).await.unwrap(); let daos = DAOs::new(pool); - let app = App::new(Box::new(mock_bot), daos.clone()); - let me_user = create_tele_user("me"); + let me_user = create_tele_user(|user| user.username = Some("me".to_string())); let me = Me { user: me_user, can_join_groups: true, @@ -188,7 +215,107 @@ pub async fn create_deps(mock_bot: MockMessageSender) -> DependencyMap { can_connect_to_business: true, has_main_web_app: true, }; - dptree::deps![dialog_storage, app, me, daos] + dptree::deps![dialog_storage, me, daos] +} + +pub async fn with_dialogue(mut deps: DependencyMap, user: &PersistedUser) -> DependencyMap { + let storage = deps.get::>>().deref().clone(); + let dialogue = RootDialogue::new(storage, user.telegram_id.into()); + dialogue.update(DialogueRootState::Start).await.unwrap(); + deps.insert(dialogue); + deps +} + +pub async fn with_message_sender( + mut deps: DependencyMap, + mock_bot: MockMessageSender, +) -> DependencyMap { + let app = App::new( + Box::new(mock_bot), + deps.get::().deref().clone(), + "bot_username".to_string(), + ); + deps.insert(app); + 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) -> (), +) -> PersistedUser { + let user_dao = deps.get::().user.clone(); + let mut new_user = NewUser { + persisted: (), + telegram_id: 12345.into(), + first_name: "Test User".to_string(), + last_name: None, + username: Some("testuser".to_string()), + is_banned: false, + }; + user_fn(&mut new_user); + user_dao.insert_user(&new_user).await.unwrap() +} + +pub async fn with_test_listing( + deps: &DependencyMap, + seller: &PersistedUser, + listing_fn: impl FnOnce(&mut NewListing) -> (), +) -> PersistedListing { + let listing_dao = deps.get::().listing.clone(); + let mut new_listing = NewListing { + persisted: (), + base: ListingBase { + seller_id: seller.persisted.id, + title: "Test Listing".to_string(), + description: Some("Test description".to_string()), + starts_at: Utc::now(), + ends_at: Utc::now() + Duration::days(3), + currency_type: CurrencyType::Usd, + }, + fields: ListingFields::BasicAuction(BasicAuctionFields { + starting_bid: MoneyAmount::from_str("100.00").unwrap(), + buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()), + min_increment: MoneyAmount::from_str("1.00").unwrap(), + anti_snipe_minutes: Some(5), + }), + }; + listing_fn(&mut new_listing); + listing_dao.insert_listing(&new_listing).await.unwrap() +} + +pub async fn create_test_listing(dao: &ListingDAO, seller: &PersistedUser) -> PersistedListing { + dao.insert_listing(&NewListing { + persisted: (), + base: ListingBase { + seller_id: seller.persisted.id, + title: "Test Listing".to_string(), + description: Some("Test description".to_string()), + starts_at: Utc::now(), + ends_at: Utc::now() + Duration::days(3), + currency_type: CurrencyType::Usd, + }, + fields: BasicAuction(BasicAuctionFields { + starting_bid: MoneyAmount::from_str("100.00").unwrap(), + buy_now_price: Some(MoneyAmount::from_str("100.00").unwrap()), + min_increment: MoneyAmount::from_str("1.00").unwrap(), + anti_snipe_minutes: Some(5), + }), + }) + .await + .unwrap() } #[cfg(test)]