integration tests for confirm bid amount
This commit is contained in:
94
Cargo.lock
generated
94
Cargo.lock
generated
@@ -560,6 +560,15 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
@@ -847,6 +856,25 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -965,6 +993,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -976,6 +1005,22 @@ dependencies = [
|
|||||||
"want",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -1011,9 +1056,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1667,6 +1714,7 @@ dependencies = [
|
|||||||
"num",
|
"num",
|
||||||
"paste",
|
"paste",
|
||||||
"regex",
|
"regex",
|
||||||
|
"reqwest",
|
||||||
"rstest",
|
"rstest",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"seq-macro",
|
"seq-macro",
|
||||||
@@ -2026,16 +2074,20 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -2783,6 +2835,27 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"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]]
|
[[package]]
|
||||||
name = "take_mut"
|
name = "take_mut"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3006,6 +3079,16 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.17"
|
version = "0.1.17"
|
||||||
@@ -3421,6 +3504,17 @@ version = "0.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ dptree = "0.5.1"
|
|||||||
seq-macro = "0.3.6"
|
seq-macro = "0.3.6"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
mockall = "0.13.1"
|
mockall = "0.13.1"
|
||||||
|
reqwest = "0.12.23"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.26.1"
|
rstest = "0.26.1"
|
||||||
|
|||||||
249
src/bidding/confirm_bid_amount_callback.rs
Normal file
249
src/bidding/confirm_bid_amount_callback.rs
Normal file
@@ -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::<DAOs>()
|
||||||
|
.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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,30 @@
|
|||||||
|
mod confirm_bid_amount_callback;
|
||||||
mod keyboards;
|
mod keyboards;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
bidding::confirm_bid_amount_callback::{
|
||||||
|
handle_awaiting_bid_amount_input, handle_awaiting_confirm_bid_amount_callback,
|
||||||
|
},
|
||||||
case,
|
case,
|
||||||
commands::new_listing::validations::{validate_price, SetFieldError},
|
|
||||||
db::{
|
db::{
|
||||||
bid::NewBid,
|
|
||||||
listing::{ListingFields, PersistedListing},
|
listing::{ListingFields, PersistedListing},
|
||||||
user::PersistedUser,
|
user::PersistedUser,
|
||||||
DbListingId, MoneyAmount, UserDAO,
|
DbListingId, DbUserId, MoneyAmount, UserDAO,
|
||||||
},
|
},
|
||||||
dptree_utils::MapTwo,
|
dptree_utils::MapTwo,
|
||||||
handle_error::with_error_handler,
|
handle_error::with_error_handler,
|
||||||
handler_utils::find_listing_by_id,
|
handler_utils::find_listing_by_id,
|
||||||
|
message_utils::buyer_name_or_link,
|
||||||
start_command_data::StartCommandData,
|
start_command_data::StartCommandData,
|
||||||
App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue,
|
App, BotError, BotHandler, BotResult, DialogueRootState, RootDialogue,
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::Context;
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use teloxide::{
|
use teloxide::{
|
||||||
dispatching::UpdateFilterExt,
|
dispatching::UpdateFilterExt,
|
||||||
types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update},
|
types::{InlineKeyboardButton, InlineKeyboardMarkup, Update},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[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)),
|
.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(
|
.branch(
|
||||||
Update::filter_message()
|
Update::filter_message()
|
||||||
.chain(case![DialogueRootState::Bidding(
|
.chain(case![DialogueRootState::Bidding(
|
||||||
@@ -84,7 +95,7 @@ async fn handle_place_bid_on_listing(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(BotError::UserVisibleError("Seller not found".to_string()))?;
|
.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,
|
ListingFields::BasicAuction(fields) => fields,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(BotError::UserVisibleError(
|
return Err(BotError::UserVisibleError(
|
||||||
@@ -100,15 +111,16 @@ async fn handle_place_bid_on_listing(
|
|||||||
|
|
||||||
let mut response_lines = vec![];
|
let mut response_lines = vec![];
|
||||||
response_lines.push(format!(
|
response_lines.push(format!(
|
||||||
"Place bid on listing for listing <b>{}</b>, ran by {}",
|
"Placing a bid <b>{title}</b>, ran by {seller}",
|
||||||
listing.base.title,
|
title = listing.base.title,
|
||||||
seller
|
seller = buyer_name_or_link(&seller)
|
||||||
.username
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| seller.telegram_id.to_string())
|
|
||||||
));
|
));
|
||||||
|
let currency_type = listing.base.currency_type;
|
||||||
response_lines.push(format!("You are bidding on this listing as: {user:?}"));
|
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()
|
let keyboard = InlineKeyboardMarkup::default()
|
||||||
.append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]);
|
.append_row([InlineKeyboardButton::callback("Bid $1", "cancel")]);
|
||||||
@@ -120,121 +132,58 @@ async fn handle_place_bid_on_listing(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_awaiting_bid_amount_input(
|
async fn handle_view_listing_bids(
|
||||||
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(
|
|
||||||
app: App,
|
app: App,
|
||||||
listing: PersistedListing,
|
listing: PersistedListing,
|
||||||
user: PersistedUser,
|
user: PersistedUser,
|
||||||
bid_amount: MoneyAmount,
|
|
||||||
dialogue: RootDialogue,
|
|
||||||
callback_query: CallbackQuery,
|
|
||||||
) -> BotResult {
|
) -> BotResult {
|
||||||
let callback_data = callback_query
|
if listing.base.seller_id != user.persisted.id {
|
||||||
.data
|
return Err(BotError::user_visible(
|
||||||
.as_deref()
|
"You are not the seller of this listing",
|
||||||
.ok_or(BotError::user_visible("Missing data in callback query"))?;
|
));
|
||||||
|
}
|
||||||
|
let currency_type = listing.base.currency_type;
|
||||||
|
|
||||||
let bid_amount = match callback_data {
|
let bids = app.daos.bid.bids_for_listing(listing.persisted.id).await?;
|
||||||
"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, user.persisted.id, bid_amount);
|
let mut response_lines = vec![];
|
||||||
app.daos.bid.insert_bid(bid).await?;
|
response_lines.push(format!(
|
||||||
|
"🔍 <b>Bids on <i>{title}</i></b>",
|
||||||
|
title = listing.base.title
|
||||||
|
));
|
||||||
|
|
||||||
dialogue.exit().await.context("failed to exit dialogue")?;
|
let bidding_users = app
|
||||||
|
|
||||||
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
|
|
||||||
.daos
|
.daos
|
||||||
.bid
|
.user
|
||||||
.bids_for_listing(listing.persisted.id)
|
.where_in_ids(bids.iter().map(|bid| bid.buyer_id))
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|bid| bid.buyer_id)
|
.map(|bid| (bid.persisted.id, bid))
|
||||||
.filter(|id| *id != user.persisted.id);
|
.collect::<HashMap<DbUserId, PersistedUser>>();
|
||||||
|
|
||||||
let other_bidders = app.daos.user.where_in_ids(other_bidder_ids).await?;
|
if let Some(current_bid) = bids.first() {
|
||||||
for bidder in other_bidders {
|
let buyer = bidding_users
|
||||||
app.bot
|
.get(¤t_bid.buyer_id)
|
||||||
.with_target(bidder.into())
|
.ok_or(BotError::internal("Buyer not found"))?;
|
||||||
.send_html_message(
|
response_lines.push(format!(
|
||||||
format!(
|
"💰 Current highest bid: <b>{current_bid}</b> from {buyer_name}",
|
||||||
"You have been outbid for {bid_amount_str} on {}",
|
current_bid = current_bid.bid_amount.with_type(currency_type),
|
||||||
listing.base.title
|
buyer_name = buyer_name_or_link(&buyer)
|
||||||
),
|
));
|
||||||
None,
|
} else {
|
||||||
)
|
response_lines.push("💰 No bids yet".to_string());
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO - keyboard with buttons to:
|
for bid in bids.iter() {
|
||||||
// - be notified if they are outbid
|
let bidder = bidding_users
|
||||||
// - be notified when the auction ends
|
.get(&bid.buyer_id)
|
||||||
// - view details about the auction
|
.ok_or(BotError::internal("Bidder not found"))?;
|
||||||
|
response_lines.push(format!(
|
||||||
|
"💰 Bid: <b>{bid_amount}</b> from {buyer_name}",
|
||||||
|
bid_amount = bid.bid_amount.with_type(currency_type),
|
||||||
|
buyer_name = buyer_name_or_link(&bidder)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
168
src/bot_message_sender.rs
Normal file
168
src/bot_message_sender.rs
Normal file
@@ -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<InlineKeyboardMarkup>,
|
||||||
|
) -> 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<InlineQueryResult>,
|
||||||
|
) -> 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<Me> {
|
||||||
|
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 <b>{bid_amount}</b> 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 <b>{bid_amount}</b> on {title}",
|
||||||
|
bid_amount = bid.bid_amount.with_type(listing.base.currency_type),
|
||||||
|
title = listing.base.title,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ impl BotError {
|
|||||||
pub fn user_visible(msg: impl Into<String>) -> Self {
|
pub fn user_visible(msg: impl Into<String>) -> Self {
|
||||||
Self::UserVisibleError(msg.into())
|
Self::UserVisibleError(msg.into())
|
||||||
}
|
}
|
||||||
|
pub fn internal(msg: impl Into<String>) -> Self {
|
||||||
|
Self::InternalError(anyhow::anyhow!(msg.into()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type BotResult<T = ()> = Result<T, BotError>;
|
pub type BotResult<T = ()> = Result<T, BotError>;
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ use crate::{
|
|||||||
start_command_data::StartCommandData,
|
start_command_data::StartCommandData,
|
||||||
App, BotError, BotResult, Command, DialogueRootState, RootDialogue,
|
App, BotError, BotResult, Command, DialogueRootState, RootDialogue,
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::Context;
|
||||||
use base64::{prelude::BASE64_URL_SAFE, Engine};
|
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use teloxide::{
|
use teloxide::{
|
||||||
@@ -58,7 +57,7 @@ pub fn my_listings_handler() -> Handler<'static, BotResult, DpHandlerDescription
|
|||||||
.branch(
|
.branch(
|
||||||
Update::filter_message()
|
Update::filter_message()
|
||||||
.filter_map(StartCommandData::get_from_update)
|
.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)
|
.filter_map_async(find_listing_by_id)
|
||||||
.endpoint(with_error_handler(handle_view_listing_details)),
|
.endpoint(with_error_handler(handle_view_listing_details)),
|
||||||
)
|
)
|
||||||
@@ -116,31 +115,14 @@ async fn handle_forward_listing(
|
|||||||
) -> BotResult {
|
) -> BotResult {
|
||||||
info!("Handling forward listing inline query for listing {listing:?}");
|
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
|
// Create inline keyboard with auction interaction buttons
|
||||||
let keyboard = InlineKeyboardMarkup::default()
|
let keyboard = InlineKeyboardMarkup::default()
|
||||||
.append_row([
|
.append_row([
|
||||||
InlineKeyboardButton::url(
|
InlineKeyboardButton::url(
|
||||||
"💰 Place Bid?",
|
"💰 Place Bid",
|
||||||
format!(
|
app.url_for_start_command(StartCommandData::PlaceBidOnListing(
|
||||||
"tg://resolve?domain={}&start={}",
|
listing.persisted.id,
|
||||||
bot_username,
|
)),
|
||||||
BASE64_URL_SAFE
|
|
||||||
.encode(format!("place_bid_on_listing:{}", listing.persisted.id))
|
|
||||||
)
|
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
),
|
),
|
||||||
InlineKeyboardButton::callback(
|
InlineKeyboardButton::callback(
|
||||||
"👀 Watch",
|
"👀 Watch",
|
||||||
@@ -149,13 +131,9 @@ async fn handle_forward_listing(
|
|||||||
])
|
])
|
||||||
.append_row([InlineKeyboardButton::url(
|
.append_row([InlineKeyboardButton::url(
|
||||||
"🔗 View Full Details",
|
"🔗 View Full Details",
|
||||||
format!(
|
app.url_for_start_command(StartCommandData::ViewListingDetailsAsBuyer(
|
||||||
"tg://resolve?domain={}&start={}",
|
listing.persisted.id,
|
||||||
bot_username,
|
)),
|
||||||
BASE64_URL_SAFE.encode(format!("view_listing_details:{}", listing.persisted.id))
|
|
||||||
)
|
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
// Get the current price based on listing type
|
// Get the current price based on listing type
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
//! This module handles all callback query processing for buttons
|
//! This module handles all callback query processing for buttons
|
||||||
//! in the new listing creation and editing workflows.
|
//! in the new listing creation and editing workflows.
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::{
|
commands::{
|
||||||
my_listings::enter_my_listings,
|
my_listings::enter_my_listings,
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ async fn save_listing(listing_dao: &ListingDAO, draft: ListingDraft) -> BotResul
|
|||||||
(listing, "Listing updated!")
|
(listing, "Listing updated!")
|
||||||
} else {
|
} else {
|
||||||
let listing = listing_dao
|
let listing = listing_dao
|
||||||
.insert_listing(NewListing {
|
.insert_listing(&NewListing {
|
||||||
persisted: (),
|
persisted: (),
|
||||||
base: draft.base,
|
base: draft.base,
|
||||||
fields: draft.fields,
|
fields: draft.fields,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
|
||||||
use crate::db::{CurrencyType, MoneyAmount};
|
use crate::db::{CurrencyType, MoneyAmount};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::db::{
|
use crate::db::{
|
||||||
bid::{NewBid, PersistedBid, PersistedBidFields},
|
bid::{NewBid, PersistedBid, PersistedBidFields},
|
||||||
bind_fields::BindFields,
|
bind_fields::BindFields,
|
||||||
DbListingId,
|
DbListingId, DbUserId,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -19,7 +19,7 @@ impl BidDAO {
|
|||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
impl BidDAO {
|
impl BidDAO {
|
||||||
pub async fn insert_bid(&self, bid: NewBid) -> Result<PersistedBid> {
|
pub async fn insert_bid(&self, bid: &NewBid) -> Result<PersistedBid> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let binds = BindFields::default()
|
let binds = BindFields::default()
|
||||||
.push("listing_id", &bid.listing_id)
|
.push("listing_id", &bid.listing_id)
|
||||||
@@ -48,11 +48,22 @@ impl BidDAO {
|
|||||||
Ok(FromRow::from_row(&row)?)
|
Ok(FromRow::from_row(&row)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn bidder_ids_for_listing(&self, listing_id: DbListingId) -> Result<Vec<DbUserId>> {
|
||||||
|
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<Vec<PersistedBid>> {
|
pub async fn bids_for_listing(&self, listing_id: DbListingId) -> Result<Vec<PersistedBid>> {
|
||||||
let rows = sqlx::query_as("SELECT * FROM bids WHERE listing_id = ?")
|
let rows = sqlx::query_as::<_, PersistedBid>(
|
||||||
.bind(listing_id)
|
"SELECT * FROM bids WHERE listing_id = ? ORDER BY bid_amount DESC",
|
||||||
.fetch_all(&self.0)
|
)
|
||||||
.await?;
|
.bind(listing_id)
|
||||||
|
.fetch_all(&self.0)
|
||||||
|
.await?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +95,8 @@ impl FromRow<'_, SqliteRow> for PersistedBidFields {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
listing::{BasicAuctionFields, ListingFields},
|
listing::{BasicAuctionFields, ListingFields},
|
||||||
@@ -141,7 +154,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let listing = listing_dao
|
let listing = listing_dao
|
||||||
.insert_listing(new_listing)
|
.insert_listing(&new_listing)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to insert test listing");
|
.expect("Failed to insert test listing");
|
||||||
|
|
||||||
@@ -172,7 +185,7 @@ mod tests {
|
|||||||
|
|
||||||
// Insert bid
|
// Insert bid
|
||||||
let inserted_bid = bid_dao
|
let inserted_bid = bid_dao
|
||||||
.insert_bid(new_bid.clone())
|
.insert_bid(&new_bid)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to insert bid");
|
.expect("Failed to insert bid");
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ impl ListingDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a new listing into the database
|
/// Insert a new listing into the database
|
||||||
pub async fn insert_listing(&self, listing: NewListing) -> Result<PersistedListing> {
|
pub async fn insert_listing(&self, listing: &NewListing) -> Result<PersistedListing> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
let binds = binds_for_listing(&listing)
|
let binds = binds_for_listing(&listing)
|
||||||
|
|||||||
@@ -156,9 +156,14 @@ impl UserDAO {
|
|||||||
) -> Result<Vec<PersistedUser>> {
|
) -> Result<Vec<PersistedUser>> {
|
||||||
let mut builder =
|
let mut builder =
|
||||||
sqlx::query_builder::QueryBuilder::new("SELECT * FROM users WHERE id IN (");
|
sqlx::query_builder::QueryBuilder::new("SELECT * FROM users WHERE id IN (");
|
||||||
|
let mut count = 0;
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
count += 1;
|
||||||
builder.push_bind(id);
|
builder.push_bind(id);
|
||||||
}
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
builder.push(")");
|
builder.push(")");
|
||||||
|
|
||||||
let rows = builder.build().fetch_all(&self.0).await?;
|
let rows = builder.build().fetch_all(&self.0).await?;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ pub type PersistedBid = Bid<PersistedBidFields>;
|
|||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub type NewBid = Bid<()>;
|
pub type NewBid = Bid<()>;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct PersistedBidFields {
|
pub struct PersistedBidFields {
|
||||||
pub id: DbBidId,
|
pub id: DbBidId,
|
||||||
@@ -15,7 +15,7 @@ pub struct PersistedBidFields {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Actual bids placed on listings
|
/// Actual bids placed on listings
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct Bid<P> {
|
pub struct Bid<P> {
|
||||||
pub persisted: P,
|
pub persisted: P,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
|
//! The main `Listing` enum ensures that only valid fields are accessible for each type.
|
||||||
//! Database mapping is handled through `ListingRow` with conversion traits.
|
//! 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 chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
@@ -127,6 +127,8 @@ impl From<&ListingFields> for ListingType {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::{DbTelegramUserId, UserDAO};
|
use crate::db::{DbTelegramUserId, UserDAO};
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
@@ -195,7 +197,7 @@ mod tests {
|
|||||||
|
|
||||||
// Insert using DAO
|
// Insert using DAO
|
||||||
let created_listing = listing_dao
|
let created_listing = listing_dao
|
||||||
.insert_listing(new_listing.clone())
|
.insert_listing(&new_listing)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to insert listing");
|
.expect("Failed to insert listing");
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ pub type PersistedUser = DbUser<PersistedUserFields>;
|
|||||||
pub type NewUser = DbUser<()>;
|
pub type NewUser = DbUser<()>;
|
||||||
|
|
||||||
/// Core user information
|
/// Core user information
|
||||||
#[derive(Clone, FromRow)]
|
#[derive(Clone, FromRow, PartialEq)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct DbUser<P: Debug + Clone> {
|
pub struct DbUser<P: Debug + Clone> {
|
||||||
pub persisted: P,
|
pub persisted: P,
|
||||||
@@ -35,7 +35,7 @@ impl Debug for DbUser<PersistedUserFields> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct PersistedUserFields {
|
pub struct PersistedUserFields {
|
||||||
pub id: DbUserId,
|
pub id: DbUserId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod currency_type;
|
mod currency_type;
|
||||||
mod db_id;
|
mod db_id;
|
||||||
mod listing_duration;
|
mod listing_duration;
|
||||||
|
mod money;
|
||||||
mod money_amount;
|
mod money_amount;
|
||||||
|
|
||||||
// Re-export all types for easy access
|
// Re-export all types for easy access
|
||||||
@@ -8,4 +9,5 @@ mod money_amount;
|
|||||||
pub use currency_type::*;
|
pub use currency_type::*;
|
||||||
pub use db_id::*;
|
pub use db_id::*;
|
||||||
pub use listing_duration::*;
|
pub use listing_duration::*;
|
||||||
|
pub use money::*;
|
||||||
pub use money_amount::*;
|
pub use money_amount::*;
|
||||||
|
|||||||
43
src/db/types/money.rs
Normal file
43
src/db/types/money.rs
Normal file
@@ -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<std::cmp::Ordering> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ use sqlx::{
|
|||||||
use std::ops::{Add, Sub};
|
use std::ops::{Add, Sub};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use crate::db::{CurrencyType, Money};
|
||||||
|
|
||||||
/// Newtype wrapper for monetary amounts stored as integer cents
|
/// Newtype wrapper for monetary amounts stored as integer cents
|
||||||
/// Stores as INTEGER in SQLite for precise comparisons and simple arithmetic
|
/// Stores as INTEGER in SQLite for precise comparisons and simple arithmetic
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
@@ -23,12 +25,6 @@ impl MoneyAmount {
|
|||||||
Self(cents)
|
Self(cents)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a MoneyAmount from a string representation (e.g., "12.34")
|
|
||||||
pub fn from_str(s: &str) -> Result<Self, rust_decimal::Error> {
|
|
||||||
let decimal = Decimal::from_str(s)?;
|
|
||||||
Ok(Self::new(decimal))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a zero MoneyAmount
|
/// Create a zero MoneyAmount
|
||||||
pub fn zero() -> Self {
|
pub fn zero() -> Self {
|
||||||
Self(0)
|
Self(0)
|
||||||
@@ -43,6 +39,20 @@ impl MoneyAmount {
|
|||||||
pub fn to_decimal(self) -> Decimal {
|
pub fn to_decimal(self) -> Decimal {
|
||||||
Decimal::new(self.0, 2) // 2 decimal places for cents
|
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<Self, Self::Err> {
|
||||||
|
let decimal = Decimal::from_str(s)?;
|
||||||
|
Ok(Self::new(decimal))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MoneyAmount {
|
impl Default for MoneyAmount {
|
||||||
@@ -475,9 +485,8 @@ mod tests {
|
|||||||
setup_test_data_for_queries(&pool).await;
|
setup_test_data_for_queries(&pool).await;
|
||||||
|
|
||||||
let threshold_amount = MoneyAmount::from_str(threshold).unwrap();
|
let threshold_amount = MoneyAmount::from_str(threshold).unwrap();
|
||||||
let query = format!(
|
let query =
|
||||||
"SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {operator} ?"
|
format!("SELECT COUNT(*) as count FROM test_bids WHERE bid_amount {operator} ?");
|
||||||
);
|
|
||||||
|
|
||||||
let count_row = sqlx::query(&query)
|
let count_row = sqlx::query(&query)
|
||||||
.bind(threshold_amount)
|
.bind(threshold_amount)
|
||||||
|
|||||||
64
src/main.rs
64
src/main.rs
@@ -1,4 +1,5 @@
|
|||||||
mod bidding;
|
mod bidding;
|
||||||
|
mod bot_message_sender;
|
||||||
mod bot_result;
|
mod bot_result;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -7,6 +8,7 @@ mod dptree_utils;
|
|||||||
mod handle_error;
|
mod handle_error;
|
||||||
mod handler_utils;
|
mod handler_utils;
|
||||||
mod keyboard_utils;
|
mod keyboard_utils;
|
||||||
|
mod message;
|
||||||
mod message_sender;
|
mod message_sender;
|
||||||
mod message_target;
|
mod message_target;
|
||||||
mod message_utils;
|
mod message_utils;
|
||||||
@@ -19,6 +21,7 @@ mod wrap_endpoint;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::bidding::{bidding_handler, BiddingState};
|
use crate::bidding::{bidding_handler, BiddingState};
|
||||||
|
use crate::bot_message_sender::BotMessageSender;
|
||||||
use crate::commands::{
|
use crate::commands::{
|
||||||
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
|
my_listings::{my_listings_handler, my_listings_inline_handler, MyListingsState},
|
||||||
new_listing::{new_listing_handler, NewListingState},
|
new_listing::{new_listing_handler, NewListingState},
|
||||||
@@ -26,9 +29,11 @@ use crate::commands::{
|
|||||||
use crate::db::DAOs;
|
use crate::db::DAOs;
|
||||||
use crate::handle_error::with_error_handler;
|
use crate::handle_error::with_error_handler;
|
||||||
use crate::handler_utils::{find_or_create_db_user_from_update, update_into_message_target};
|
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 crate::sqlite_storage::SqliteStorage;
|
||||||
use anyhow::Result;
|
use crate::start_command_data::StartCommandData;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
pub use bot_result::*;
|
pub use bot_result::*;
|
||||||
use commands::*;
|
use commands::*;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
@@ -43,17 +48,36 @@ pub use wrap_endpoint::*;
|
|||||||
pub struct App {
|
pub struct App {
|
||||||
pub bot: Arc<BoxedMessageSender>,
|
pub bot: Arc<BoxedMessageSender>,
|
||||||
pub daos: DAOs,
|
pub daos: DAOs,
|
||||||
|
pub bot_username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(bot: BoxedMessageSender, daos: DAOs) -> Self {
|
pub fn new(bot: BoxedMessageSender, daos: DAOs, bot_username: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bot: Arc::new(bot),
|
bot: Arc::new(bot),
|
||||||
daos,
|
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
|
/// Set up the bot's command menu that appears when users tap the menu button
|
||||||
async fn setup_bot_commands(bot: &Bot) -> Result<()> {
|
async fn setup_bot_commands(bot: &Bot) -> Result<()> {
|
||||||
info!("Setting up bot command menu...");
|
info!("Setting up bot command menu...");
|
||||||
@@ -157,20 +181,34 @@ async fn main() -> Result<()> {
|
|||||||
setup_bot_commands(&bot).await?;
|
setup_bot_commands(&bot).await?;
|
||||||
|
|
||||||
let handler_with_deps = dptree::entry()
|
let handler_with_deps = dptree::entry()
|
||||||
.filter_map(|bot: Box<Bot>, update: Update, daos: DAOs| {
|
.filter_map(
|
||||||
let target = update_into_message_target(update)?;
|
|bot: Box<Bot>, update: Update, daos: DAOs, bot_username: BotUsername| {
|
||||||
Some(App::new(
|
let target = update_into_message_target(update)?;
|
||||||
Box::new(BotMessageSender::new(*bot, target)),
|
Some(App::new(
|
||||||
daos.clone(),
|
Box::new(BotMessageSender::new(*bot, target)),
|
||||||
))
|
daos.clone(),
|
||||||
})
|
bot_username.0,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
.chain(main_handler());
|
.chain(main_handler());
|
||||||
|
|
||||||
let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
|
let dialog_storage = SqliteStorage::new(db_pool.clone(), Json).await?;
|
||||||
let daos = DAOs::new(db_pool.clone());
|
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)
|
Dispatcher::builder(bot, handler_with_deps)
|
||||||
.dependencies(dptree::deps![dialog_storage, daos])
|
.dependencies(dptree::deps![
|
||||||
|
dialog_storage,
|
||||||
|
daos,
|
||||||
|
BotUsername(bot_username)
|
||||||
|
])
|
||||||
.enable_ctrlc_handler()
|
.enable_ctrlc_handler()
|
||||||
.worker_queue_size(1)
|
.worker_queue_size(1)
|
||||||
.build()
|
.build()
|
||||||
@@ -196,7 +234,7 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::message_sender::MockMessageSender;
|
use crate::message_sender::MockMessageSender;
|
||||||
use crate::test_utils::{create_deps, create_tele_update};
|
use crate::test_utils::*;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_main_handler() {
|
async fn test_main_handler() {
|
||||||
@@ -209,7 +247,7 @@ mod tests {
|
|||||||
always(),
|
always(),
|
||||||
)
|
)
|
||||||
.returning(|_, _| Ok(()));
|
.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"));
|
deps.insert(create_tele_update("/help"));
|
||||||
let handler = main_handler();
|
let handler = main_handler();
|
||||||
dptree::type_check(handler.sig(), &deps, &[]);
|
dptree::type_check(handler.sig(), &deps, &[]);
|
||||||
|
|||||||
19
src/message/mod.rs
Normal file
19
src/message/mod.rs
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
use crate::{BotError, BotResult, MessageTarget};
|
use crate::{message::MessageType, BotResult, MessageTarget};
|
||||||
use anyhow::Context;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use teloxide::{
|
use teloxide::types::{
|
||||||
payloads::{EditMessageTextSetters, SendMessageSetters},
|
CallbackQueryId, InlineKeyboardMarkup, InlineQueryId, InlineQueryResult, Me,
|
||||||
prelude::Requester,
|
|
||||||
types::{
|
|
||||||
CallbackQueryId, InlineKeyboardMarkup, InlineQueryId, InlineQueryResult, Me, ParseMode,
|
|
||||||
},
|
|
||||||
Bot,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -25,6 +19,7 @@ pub trait MessageSender {
|
|||||||
) -> BotResult;
|
) -> BotResult;
|
||||||
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
|
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
|
||||||
async fn get_me(&self) -> BotResult<Me>;
|
async fn get_me(&self) -> BotResult<Me>;
|
||||||
|
async fn send_message(&self, message: MessageType) -> BotResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type BoxedMessageSender = Box<dyn MessageSender + Send + Sync>;
|
pub type BoxedMessageSender = Box<dyn MessageSender + Send + Sync>;
|
||||||
@@ -50,76 +45,6 @@ mockall::mock! {
|
|||||||
) -> BotResult;
|
) -> BotResult;
|
||||||
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
|
async fn answer_callback_query(&self, query_id: CallbackQueryId) -> BotResult;
|
||||||
async fn get_me(&self) -> BotResult<Me>;
|
async fn get_me(&self) -> BotResult<Me>;
|
||||||
}
|
async fn send_message(&self, message: MessageType) -> 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<InlineKeyboardMarkup>,
|
|
||||||
) -> 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<InlineQueryResult>,
|
|
||||||
) -> 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<Me> {
|
|
||||||
self.0
|
|
||||||
.get_me()
|
|
||||||
.await
|
|
||||||
.map_err(|err| BotError::InternalError(err.into()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{message_sender::BoxedMessageSender, BotResult};
|
use crate::{db::user::PersistedUser, message_sender::BoxedMessageSender, BotResult};
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use num::One;
|
use num::One;
|
||||||
@@ -56,3 +56,17 @@ pub fn pluralize_with_count<N: One + PartialEq<N> + Display + Copy>(
|
|||||||
pub fn format_datetime(dt: DateTime<Utc>) -> String {
|
pub fn format_datetime(dt: DateTime<Utc>) -> String {
|
||||||
dt.format("%b %d, %Y %H:%M UTC").to_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!("<a href='{link}'>@{username} ({name})</a>")
|
||||||
|
} else {
|
||||||
|
format!("<a href='{link}'>{name}</a>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,88 @@
|
|||||||
use base64::{prelude::BASE64_URL_SAFE, Engine};
|
use base64::{prelude::BASE64_URL_SAFE, Engine};
|
||||||
use log::info;
|
use teloxide::types::{CallbackQuery, MediaKind, MessageKind, UpdateKind};
|
||||||
use teloxide::types::{MediaKind, MessageKind, UpdateKind};
|
|
||||||
|
|
||||||
use crate::db::DbListingId;
|
use crate::db::DbListingId;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
|
||||||
pub enum StartCommandData {
|
pub enum StartCommandData {
|
||||||
PlaceBidOnListing(DbListingId),
|
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<StartCommandData> for String {
|
impl From<StartCommandData> for String {
|
||||||
fn from(value: StartCommandData) -> Self {
|
fn from(value: StartCommandData) -> Self {
|
||||||
match value {
|
match value {
|
||||||
StartCommandData::PlaceBidOnListing(listing_id) => {
|
StartCommandData::PlaceBidOnListing(listing_id) => {
|
||||||
format!("place_bid_on_listing:{listing_id}")
|
format!("{PLACE_BID_ON_LISTING}:{listing_id}")
|
||||||
}
|
}
|
||||||
StartCommandData::ViewListingDetails(listing_id) => {
|
StartCommandData::ViewListingDetailsAsBuyer(listing_id) => {
|
||||||
format!("view_listing_details:{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<String> for StartCommandData {
|
||||||
|
type Error = StartCommandDataError;
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::try_from(value.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&String> for StartCommandData {
|
||||||
|
type Error = StartCommandDataError;
|
||||||
|
fn try_from(value: &String) -> Result<Self, Self::Error> {
|
||||||
|
Self::try_from(value.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for StartCommandData {
|
||||||
|
type Error = StartCommandDataError;
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
let mut parts = value.split(":").map(|s| s.trim());
|
||||||
|
let name = parts
|
||||||
|
.next()
|
||||||
|
.ok_or(StartCommandDataError::MissingCommandName)?;
|
||||||
|
|
||||||
|
let mut get_id_part = || -> Result<i64, StartCommandDataError> {
|
||||||
|
let id_part = parts.next().ok_or(StartCommandDataError::MissingIdPart)?;
|
||||||
|
id_part.parse::<i64>().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 {
|
impl StartCommandData {
|
||||||
pub fn get_from_update(update: teloxide::types::Update) -> Option<StartCommandData> {
|
pub fn get_from_update(update: teloxide::types::Update) -> Option<StartCommandData> {
|
||||||
let message = match update.kind {
|
let message = match update.kind {
|
||||||
@@ -40,17 +100,17 @@ impl StartCommandData {
|
|||||||
let message = message.text.strip_prefix("/start ")?;
|
let message = message.text.strip_prefix("/start ")?;
|
||||||
let decoded = BASE64_URL_SAFE.decode(message).ok()?;
|
let decoded = BASE64_URL_SAFE.decode(message).ok()?;
|
||||||
let decoded = String::from_utf8(decoded).ok()?;
|
let decoded = String::from_utf8(decoded).ok()?;
|
||||||
let parts = decoded.split(":").map(|s| s.trim()).collect::<Vec<&str>>();
|
StartCommandData::try_from(decoded.as_str()).ok()
|
||||||
info!("command parts: {parts:?}");
|
}
|
||||||
match parts.first()?.trim() {
|
|
||||||
"place_bid_on_listing" => Some(StartCommandData::PlaceBidOnListing(DbListingId::new(
|
pub fn get_from_callback_query(callback_query: CallbackQuery) -> Option<StartCommandData> {
|
||||||
parts.get(1)?.parse::<i64>().ok()?,
|
let data = callback_query.data.as_ref()?;
|
||||||
))),
|
StartCommandData::try_from(data.as_str()).ok()
|
||||||
"view_listing_details" => Some(StartCommandData::ViewListingDetails(DbListingId::new(
|
}
|
||||||
parts.get(1)?.parse::<i64>().ok()?,
|
|
||||||
))),
|
pub fn encode_for_start_command(self) -> String {
|
||||||
_ => None,
|
let as_string: String = self.into();
|
||||||
}
|
BASE64_URL_SAFE.encode(as_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_place_bid_on_listing_start_command(
|
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,
|
command: StartCommandData,
|
||||||
) -> Option<DbListingId> {
|
) -> Option<DbListingId> {
|
||||||
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<DbListingId> {
|
||||||
|
if let StartCommandData::ViewListingBids(listing_id) = command {
|
||||||
Some(listing_id)
|
Some(listing_id)
|
||||||
} else {
|
} else {
|
||||||
None
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
//! Test utilities including timestamp comparison macros
|
//! Test utilities including timestamp comparison macros
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::{Duration, Utc};
|
||||||
use dptree::di::DependencyMap;
|
use dptree::di::DependencyMap;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
use std::{ops::Deref, str::FromStr, sync::Arc};
|
||||||
use teloxide::dispatching::dialogue::serializer::Json;
|
use teloxide::dispatching::dialogue::serializer::Json;
|
||||||
use teloxide::types::*;
|
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.
|
/// Assert that two timestamps are approximately equal within a given epsilon tolerance.
|
||||||
///
|
///
|
||||||
@@ -109,17 +123,19 @@ pub async fn create_test_pool() -> SqlitePool {
|
|||||||
pool
|
pool
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_tele_user(username: &str) -> User {
|
pub fn create_tele_user(user_fn: impl FnOnce(&mut User) -> ()) -> User {
|
||||||
User {
|
let mut user = User {
|
||||||
id: UserId(1),
|
id: UserId(1),
|
||||||
username: Some(username.to_string()),
|
username: Some("username".to_string()),
|
||||||
first_name: username.to_string(),
|
first_name: "firstname".to_string(),
|
||||||
last_name: Some("lastname".to_string()),
|
last_name: Some("lastname".to_string()),
|
||||||
is_bot: false,
|
is_bot: false,
|
||||||
language_code: Some("en".to_string()),
|
language_code: Some("en".to_string()),
|
||||||
is_premium: false,
|
is_premium: false,
|
||||||
added_to_attachment_menu: false,
|
added_to_attachment_menu: false,
|
||||||
}
|
};
|
||||||
|
user_fn(&mut user);
|
||||||
|
user
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_tele_private_chat(user: &User) -> Chat {
|
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 {
|
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);
|
let chat = create_tele_private_chat(&user);
|
||||||
Update {
|
Update {
|
||||||
id: UpdateId(1),
|
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 pool = create_test_pool().await;
|
||||||
let dialog_storage = SqliteStorage::new(pool.clone(), Json).await.unwrap();
|
let dialog_storage = SqliteStorage::new(pool.clone(), Json).await.unwrap();
|
||||||
let daos = DAOs::new(pool);
|
let daos = DAOs::new(pool);
|
||||||
let app = App::new(Box::new(mock_bot), daos.clone());
|
let me_user = create_tele_user(|user| user.username = Some("me".to_string()));
|
||||||
let me_user = create_tele_user("me");
|
|
||||||
let me = Me {
|
let me = Me {
|
||||||
user: me_user,
|
user: me_user,
|
||||||
can_join_groups: true,
|
can_join_groups: true,
|
||||||
@@ -188,7 +215,107 @@ pub async fn create_deps(mock_bot: MockMessageSender) -> DependencyMap {
|
|||||||
can_connect_to_business: true,
|
can_connect_to_business: true,
|
||||||
has_main_web_app: 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::<Arc<SqliteStorage<Json>>>().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::<DAOs>().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::<DAOs>().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::<DAOs>().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)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user