Initial commit
This commit is contained in:
commit
ea042c33ba
26 changed files with 4452 additions and 0 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
/.env
|
||||||
18
.env.template
Normal file
18
.env.template
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
TELEGRAM_API_URL=https://api.telegram.org
|
||||||
|
TELEGRAM_SECRET=
|
||||||
|
TELEGRAM_TOKEN=
|
||||||
|
TELEGRAM_CALLBACK_URL=
|
||||||
|
|
||||||
|
VK_API_URL=https://api.vk.ru
|
||||||
|
VK_TOKEN=
|
||||||
|
VK_SECRET=
|
||||||
|
VK_CONFIRMATION_CODE=
|
||||||
|
|
||||||
|
SCG_SEARCH_URL=https://starcitygamesv2.searchapi-na.hawksearch.com
|
||||||
|
SCG_CLIENT_GUID=cc3be22005ef47d3969c3de28f09571b
|
||||||
|
SCG_DISPLAY_URL=https://starcitygames.com
|
||||||
|
SCG_MAX_OFFERS=5
|
||||||
|
|
||||||
|
SCRYFALL_URL=https://api.scryfall.com
|
||||||
|
|
||||||
|
BIND_ADDRESS=0.0.0.0:3000
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
/.env
|
||||||
|
/.envrc
|
||||||
2354
Cargo.lock
generated
Normal file
2354
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "mtg-price-bot"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.101"
|
||||||
|
axum = "0.8.8"
|
||||||
|
mockall = "0.14.0"
|
||||||
|
mockito = "1.7.2"
|
||||||
|
reqwest = { version = "0.13.2", features=["json"] }
|
||||||
|
rstest = "0.26.1"
|
||||||
|
serde = { version = "1.0.228", features=["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
tokio = { version = "1.49.0", features=["full"] }
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = "0.3.22"
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY . .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
FROM chef AS builder
|
||||||
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
# Build dependencies - this is the caching Docker layer!
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
# Build application
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release --bin mtg-price-bot
|
||||||
|
|
||||||
|
# We do not need the Rust toolchain to run the binary!
|
||||||
|
FROM debian:trixie-slim AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/target/release/mtg-price-bot /usr/local/bin
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
ENTRYPOINT ["/usr/local/bin/mtg-price-bot"]
|
||||||
15
LICENSE
Normal file
15
LICENSE
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
BSD Zero Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Artyom Belousov
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
BIN
Logo.jpg
Normal file
BIN
Logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
16
README.md
Normal file
16
README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# MTG price bot
|
||||||
|
EN | [RU](https://codeberg.org/flygrounder/mtg-price-bot/src/branch/main/README_RU.md)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
VK bot: https://vk.ru/mtg_vk_bot
|
||||||
|
Telegram bot: https://t.me/MtgScgBot
|
||||||
|
|
||||||
|
Search prices for MTG cards from http://www.starcitygames.com/ without leaving your favourite messenger.
|
||||||
|
No need to learn bot commands, just send it a card name in any language and it will find prices for different printings of that card.
|
||||||
|
|
||||||
|
To find cards even more quickly, use command
|
||||||
|
!s XXX YYY
|
||||||
|
XXX - set number
|
||||||
|
YYY - collectors number
|
||||||
|
You can find them in the bottom left corner of a card.
|
||||||
16
README_RU.md
Normal file
16
README_RU.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# MTG price bot
|
||||||
|
[EN](https://codeberg.org/flygrounder/mtg-price-bot/src/branch/main/README.md) | RU
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Бот в ВК: https://vk.ru/mtg_vk_bot
|
||||||
|
Бот в Telegram: https://t.me/MtgScgBot
|
||||||
|
|
||||||
|
Ищите цены на карты MTG с сайта http://www.starcitygames.com/ не выходя из вашего любимого мессенджера.
|
||||||
|
Не нужно учить никаких команд, просто отправьте боту название карты на любом языке и он предложит вам цены на разные издания этой карты.
|
||||||
|
|
||||||
|
Если же вы хотите искать карты ещё быстрее, то воспользуйтесь командой
|
||||||
|
!s XXX YYY
|
||||||
|
XXX - короткий код издания
|
||||||
|
YYY - коллекционный номер карты
|
||||||
|
Они расположены в нижнем левом углу карты
|
||||||
8
compose.yaml
Normal file
8
compose.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
restart: unless-stopped
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:3000:3000
|
||||||
20
devbox.json
Normal file
20
devbox.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json",
|
||||||
|
"packages": [
|
||||||
|
"cargo@latest",
|
||||||
|
"rustc@latest",
|
||||||
|
"bacon@latest",
|
||||||
|
"rustfmt@latest",
|
||||||
|
"clippy@latest"
|
||||||
|
],
|
||||||
|
"shell": {
|
||||||
|
"init_hook": [
|
||||||
|
"echo 'Welcome to devbox!' > /dev/null"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": [
|
||||||
|
"echo \"Error: no test specified\" && exit 1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
294
devbox.lock
Normal file
294
devbox.lock
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
{
|
||||||
|
"lockfile_version": "1",
|
||||||
|
"packages": {
|
||||||
|
"bacon@latest": {
|
||||||
|
"last_modified": "2026-01-23T17:20:52Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#bacon",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "3.22.0",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/d1ikw8xrns7cbfqypwcgxcchwzvdq7cg-bacon-3.22.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/d1ikw8xrns7cbfqypwcgxcchwzvdq7cg-bacon-3.22.0"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/kmhjc2p8hxf3hkdja6cqi0khqda5jxv4-bacon-3.22.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/kmhjc2p8hxf3hkdja6cqi0khqda5jxv4-bacon-3.22.0"
|
||||||
|
},
|
||||||
|
"x86_64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/5ymilhncmbc9iw2il7zj5wzpv7alnhd8-bacon-3.22.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/5ymilhncmbc9iw2il7zj5wzpv7alnhd8-bacon-3.22.0"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/19ca86jyjzy2r6kppssjc0y9r8knxcpf-bacon-3.22.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/19ca86jyjzy2r6kppssjc0y9r8knxcpf-bacon-3.22.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cargo@latest": {
|
||||||
|
"last_modified": "2026-01-23T17:20:52Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#cargo",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "1.92.0",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/v4bvnkm0p5x41fhybskr0cf2zvkgyrvv-cargo-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/v4bvnkm0p5x41fhybskr0cf2zvkgyrvv-cargo-1.92.0"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/piiqs6x2m8gv0n3z3pys8scn0y673piy-cargo-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/piiqs6x2m8gv0n3z3pys8scn0y673piy-cargo-1.92.0"
|
||||||
|
},
|
||||||
|
"x86_64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/wf6279pgydd1nny4s5nx1msian6dbf9p-cargo-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/wf6279pgydd1nny4s5nx1msian6dbf9p-cargo-1.92.0"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/yqcsaywfvcyy9wmbzb5fawp29icgi7cb-cargo-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/yqcsaywfvcyy9wmbzb5fawp29icgi7cb-cargo-1.92.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clippy@latest": {
|
||||||
|
"last_modified": "2026-01-23T17:20:52Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#clippy",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "1.92.0",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/phhksxd0vv7ml9imsr0lwiqvvmiaz23p-clippy-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/phhksxd0vv7ml9imsr0lwiqvvmiaz23p-clippy-1.92.0"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/1iqky89dxzj12yy9dsbyrarknswx6iyj-clippy-1.92.0",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "debug",
|
||||||
|
"path": "/nix/store/anj7w4nnbw9cvsrsq85vnwqlw8miss5n-clippy-1.92.0-debug"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/1iqky89dxzj12yy9dsbyrarknswx6iyj-clippy-1.92.0"
|
||||||
|
},
|
||||||
|
"x86_64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/34yqzpcjb1cms86lij7wkszcjxdivx6f-clippy-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/34yqzpcjb1cms86lij7wkszcjxdivx6f-clippy-1.92.0"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/zmrbcbd77w6nylgwyagnxh87all5swjf-clippy-1.92.0",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "debug",
|
||||||
|
"path": "/nix/store/yvamzvy4r4ml91sl0fap3jvp12wgwflx-clippy-1.92.0-debug"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/zmrbcbd77w6nylgwyagnxh87all5swjf-clippy-1.92.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"github:NixOS/nixpkgs/nixpkgs-unstable": {
|
||||||
|
"last_modified": "2026-01-30T02:32:49Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/6308c3b21396534d8aaeac46179c14c439a89b8a?lastModified=1769740369&narHash=sha256-xKPyJoMoXfXpDM5DFDZDsi9PHArf2k5BJjvReYXoFpM%3D"
|
||||||
|
},
|
||||||
|
"rustc@latest": {
|
||||||
|
"last_modified": "2026-01-23T17:20:52Z",
|
||||||
|
"plugin_version": "0.0.1",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#rustc",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "1.92.0",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/ymskl36napcfgl6wjz1xdjn0jd25inrv-rustc-wrapper-1.92.0",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "man",
|
||||||
|
"path": "/nix/store/ch54xfkz0dlqvhbinzlbkva2898nvihl-rustc-wrapper-1.92.0-man",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doc",
|
||||||
|
"path": "/nix/store/09yhz82jqxwbmn4dbjy7p9hrvbr4g0mn-rustc-wrapper-1.92.0-doc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/ymskl36napcfgl6wjz1xdjn0jd25inrv-rustc-wrapper-1.92.0"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/qnvqgfiqh8s08cqp452665l2b60a811h-rustc-wrapper-1.92.0",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "man",
|
||||||
|
"path": "/nix/store/5ggpm5m3wkxj05si2id4b6sq4alf90qg-rustc-wrapper-1.92.0-man",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doc",
|
||||||
|
"path": "/nix/store/camfpqmi94ssc1p8vkygp2s621ykq80m-rustc-wrapper-1.92.0-doc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/qnvqgfiqh8s08cqp452665l2b60a811h-rustc-wrapper-1.92.0"
|
||||||
|
},
|
||||||
|
"x86_64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/zkcwgmgli06nsh0v8yv82gnh5whgcdyl-rustc-wrapper-1.92.0",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "man",
|
||||||
|
"path": "/nix/store/2k3fdy9xjwkx06rlfwfn449w442vgk0i-rustc-wrapper-1.92.0-man",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doc",
|
||||||
|
"path": "/nix/store/7271gqq93bbxrqb9rw3hf5b5x6wdm2cn-rustc-wrapper-1.92.0-doc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/zkcwgmgli06nsh0v8yv82gnh5whgcdyl-rustc-wrapper-1.92.0"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/qvpg842zrjkywv7sqgw2h05spdyzcj86-rustc-wrapper-1.92.0",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "man",
|
||||||
|
"path": "/nix/store/n1d4093lcx7ljgks1j352fbrf5w551v9-rustc-wrapper-1.92.0-man",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doc",
|
||||||
|
"path": "/nix/store/425gvb2xnhkwb1izjr97wpwdwndwi516-rustc-wrapper-1.92.0-doc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/qvpg842zrjkywv7sqgw2h05spdyzcj86-rustc-wrapper-1.92.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rustfmt@latest": {
|
||||||
|
"last_modified": "2026-01-23T17:20:52Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#rustfmt",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "1.92.0",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/max1hp93q51kfj074lw6lg8w9m5nmh10-rustfmt-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/max1hp93q51kfj074lw6lg8w9m5nmh10-rustfmt-1.92.0"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/vq6cxhd7iv2g444imys9wkmwqz3ffqbc-rustfmt-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/vq6cxhd7iv2g444imys9wkmwqz3ffqbc-rustfmt-1.92.0"
|
||||||
|
},
|
||||||
|
"x86_64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/dnjwscz1r33dkmikx3ms58qmhr60db9i-rustfmt-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/dnjwscz1r33dkmikx3ms58qmhr60db9i-rustfmt-1.92.0"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/gl4y2v4lwiyllh85z712w8ajydia053l-rustfmt-1.92.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/gl4y2v4lwiyllh85z712w8ajydia053l-rustfmt-1.92.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
317
src/application.rs
Normal file
317
src/application.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use anyhow::Result;
|
||||||
|
use mockall::automock;
|
||||||
|
|
||||||
|
use crate::domain::CardOffersFetcher;
|
||||||
|
use crate::domain::ResponseMessage;
|
||||||
|
use crate::domain::ResponseMessageFormatter;
|
||||||
|
use crate::domain::{MessageSender, OriginalCardNameFetcher, UserQueryParser};
|
||||||
|
|
||||||
|
pub struct GetCardInfoHandler<
|
||||||
|
P: UserQueryParser,
|
||||||
|
N: OriginalCardNameFetcher,
|
||||||
|
S: MessageSender,
|
||||||
|
O: CardOffersFetcher,
|
||||||
|
F: ResponseMessageFormatter,
|
||||||
|
> {
|
||||||
|
parser: P,
|
||||||
|
name_fetcher: N,
|
||||||
|
sender: S,
|
||||||
|
offers_fetcher: O,
|
||||||
|
formatter: F,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[automock]
|
||||||
|
pub trait GetCardInfoUseCase {
|
||||||
|
fn get_card_info(&self, chat_id: i64, query: &str) -> impl Future<Output = Result<()>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<
|
||||||
|
P: UserQueryParser,
|
||||||
|
N: OriginalCardNameFetcher,
|
||||||
|
S: MessageSender,
|
||||||
|
O: CardOffersFetcher,
|
||||||
|
F: ResponseMessageFormatter,
|
||||||
|
> GetCardInfoHandler<P, N, S, O, F>
|
||||||
|
{
|
||||||
|
pub fn new(parser: P, name_fetcher: N, sender: S, offers_fetcher: O, formatter: F) -> Self {
|
||||||
|
Self {
|
||||||
|
parser,
|
||||||
|
name_fetcher,
|
||||||
|
sender,
|
||||||
|
offers_fetcher,
|
||||||
|
formatter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<
|
||||||
|
P: UserQueryParser,
|
||||||
|
N: OriginalCardNameFetcher,
|
||||||
|
S: MessageSender,
|
||||||
|
O: CardOffersFetcher,
|
||||||
|
F: ResponseMessageFormatter,
|
||||||
|
> GetCardInfoUseCase for GetCardInfoHandler<P, N, S, O, F>
|
||||||
|
{
|
||||||
|
async fn get_card_info(&self, chat_id: i64, query: &str) -> Result<()> {
|
||||||
|
let request = self.parser.parse_query(query);
|
||||||
|
let original_name = match self.name_fetcher.fetch_original_card_name(&request).await {
|
||||||
|
Ok(Some(name)) => name,
|
||||||
|
Ok(None) => {
|
||||||
|
let card_not_found = self
|
||||||
|
.formatter
|
||||||
|
.format_message(&ResponseMessage::CardNotFound);
|
||||||
|
self.sender
|
||||||
|
.send_message(chat_id, &card_not_found)
|
||||||
|
.await
|
||||||
|
.context("failed to send card not found message")?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let service_unavailable = self
|
||||||
|
.formatter
|
||||||
|
.format_message(&ResponseMessage::ServiceUnavailable);
|
||||||
|
self.sender
|
||||||
|
.send_message(chat_id, &service_unavailable)
|
||||||
|
.await
|
||||||
|
.context("failed to send name fetcher error message")?;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let offers = match self.offers_fetcher.fetch_card_offers(&original_name).await {
|
||||||
|
Ok(offers) => offers,
|
||||||
|
Err(err) => {
|
||||||
|
let service_unavailable = self
|
||||||
|
.formatter
|
||||||
|
.format_message(&ResponseMessage::ServiceUnavailable);
|
||||||
|
self.sender
|
||||||
|
.send_message(chat_id, &service_unavailable)
|
||||||
|
.await
|
||||||
|
.context("failed to send offers fetcher error message")?;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let success = self.formatter.format_message(&ResponseMessage::Success {
|
||||||
|
original_name,
|
||||||
|
offers,
|
||||||
|
});
|
||||||
|
self.sender
|
||||||
|
.send_message(chat_id, &success)
|
||||||
|
.await
|
||||||
|
.context("failed to send result message")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::domain::{
|
||||||
|
CardInfoRequest, CardOffer, FormattedResponseMessage, MockCardOffersFetcher,
|
||||||
|
MockMessageSender, MockOriginalCardNameFetcher, MockResponseMessageFormatter,
|
||||||
|
MockUserQueryParser, OriginalCardName,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_card_name_error() {
|
||||||
|
let text = "Тефери, герой доминарии";
|
||||||
|
|
||||||
|
let mut parser = MockUserQueryParser::new();
|
||||||
|
parser
|
||||||
|
.expect_parse_query()
|
||||||
|
.times(1)
|
||||||
|
.withf(move |query| query == text)
|
||||||
|
.returning(|_| CardInfoRequest::ByPrintedName { name: text.into() });
|
||||||
|
|
||||||
|
let mut name_fetcher = MockOriginalCardNameFetcher::new();
|
||||||
|
name_fetcher
|
||||||
|
.expect_fetch_original_card_name()
|
||||||
|
.times(1)
|
||||||
|
.withf(|info| *info == CardInfoRequest::ByPrintedName { name: text.into() })
|
||||||
|
.returning(|_| Box::pin(async { anyhow::bail!("error") }));
|
||||||
|
|
||||||
|
let mut formatter = MockResponseMessageFormatter::new();
|
||||||
|
formatter
|
||||||
|
.expect_format_message()
|
||||||
|
.times(1)
|
||||||
|
.withf(|message| matches!(message, ResponseMessage::ServiceUnavailable))
|
||||||
|
.returning(|_| FormattedResponseMessage("service unavailable".into()));
|
||||||
|
|
||||||
|
let mut sender = MockMessageSender::new();
|
||||||
|
sender
|
||||||
|
.expect_send_message()
|
||||||
|
.times(1)
|
||||||
|
.withf(|chat_id, message| *chat_id == 123 && message.0 == "service unavailable")
|
||||||
|
.returning(|_, _| Box::pin(async { Ok(()) }));
|
||||||
|
|
||||||
|
let use_case = GetCardInfoHandler::new(
|
||||||
|
parser,
|
||||||
|
name_fetcher,
|
||||||
|
sender,
|
||||||
|
MockCardOffersFetcher::new(),
|
||||||
|
formatter,
|
||||||
|
);
|
||||||
|
|
||||||
|
use_case.get_card_info(123, text).await.unwrap_err();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_card_does_not_exist() {
|
||||||
|
let text = "invalid";
|
||||||
|
|
||||||
|
let mut parser = MockUserQueryParser::new();
|
||||||
|
parser
|
||||||
|
.expect_parse_query()
|
||||||
|
.times(1)
|
||||||
|
.withf(move |query| query == text)
|
||||||
|
.returning(|_| CardInfoRequest::ByPrintedName { name: text.into() });
|
||||||
|
|
||||||
|
let mut name_fetcher = MockOriginalCardNameFetcher::new();
|
||||||
|
name_fetcher
|
||||||
|
.expect_fetch_original_card_name()
|
||||||
|
.times(1)
|
||||||
|
.withf(|info| *info == CardInfoRequest::ByPrintedName { name: text.into() })
|
||||||
|
.returning(|_| Box::pin(async { Ok(None) }));
|
||||||
|
|
||||||
|
let mut formatter = MockResponseMessageFormatter::new();
|
||||||
|
formatter
|
||||||
|
.expect_format_message()
|
||||||
|
.times(1)
|
||||||
|
.withf(|message| matches!(message, ResponseMessage::CardNotFound))
|
||||||
|
.returning(|_| FormattedResponseMessage("not found".into()));
|
||||||
|
|
||||||
|
let mut sender = MockMessageSender::new();
|
||||||
|
sender
|
||||||
|
.expect_send_message()
|
||||||
|
.times(1)
|
||||||
|
.withf(|chat_id, message| *chat_id == 123 && message.0 == "not found")
|
||||||
|
.returning(|_, _| Box::pin(async { Ok(()) }));
|
||||||
|
|
||||||
|
let use_case = GetCardInfoHandler::new(
|
||||||
|
parser,
|
||||||
|
name_fetcher,
|
||||||
|
sender,
|
||||||
|
MockCardOffersFetcher::new(),
|
||||||
|
formatter,
|
||||||
|
);
|
||||||
|
|
||||||
|
use_case.get_card_info(123, text).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_card_price_error() {
|
||||||
|
let text = "Тефери, герой доминарии";
|
||||||
|
|
||||||
|
let mut parser = MockUserQueryParser::new();
|
||||||
|
parser
|
||||||
|
.expect_parse_query()
|
||||||
|
.times(1)
|
||||||
|
.withf(move |query| query == text)
|
||||||
|
.returning(|_| CardInfoRequest::ByPrintedName { name: text.into() });
|
||||||
|
|
||||||
|
let mut name_fetcher = MockOriginalCardNameFetcher::new();
|
||||||
|
name_fetcher
|
||||||
|
.expect_fetch_original_card_name()
|
||||||
|
.times(1)
|
||||||
|
.withf(|info| *info == CardInfoRequest::ByPrintedName { name: text.into() })
|
||||||
|
.returning(|_| {
|
||||||
|
Box::pin(async { Ok(Some(OriginalCardName("Teferi, Hero of Dominaria".into()))) })
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut offers_fetcher = MockCardOffersFetcher::new();
|
||||||
|
offers_fetcher
|
||||||
|
.expect_fetch_card_offers()
|
||||||
|
.times(1)
|
||||||
|
.withf(|query| query.0 == "Teferi, Hero of Dominaria")
|
||||||
|
.returning(|_| Box::pin(async { anyhow::bail!("error") }));
|
||||||
|
|
||||||
|
let mut formatter = MockResponseMessageFormatter::new();
|
||||||
|
formatter
|
||||||
|
.expect_format_message()
|
||||||
|
.times(1)
|
||||||
|
.withf(|message| matches!(message, ResponseMessage::ServiceUnavailable))
|
||||||
|
.returning(|_| FormattedResponseMessage("service unavailable".into()));
|
||||||
|
|
||||||
|
let mut sender = MockMessageSender::new();
|
||||||
|
sender
|
||||||
|
.expect_send_message()
|
||||||
|
.times(1)
|
||||||
|
.withf(|chat_id, message| *chat_id == 123 && message.0 == "service unavailable")
|
||||||
|
.returning(|_, _| Box::pin(async { Ok(()) }));
|
||||||
|
|
||||||
|
let use_case =
|
||||||
|
GetCardInfoHandler::new(parser, name_fetcher, sender, offers_fetcher, formatter);
|
||||||
|
|
||||||
|
use_case.get_card_info(123, text).await.unwrap_err();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_card_prices_success() {
|
||||||
|
let text = "Тефери, герой доминарии";
|
||||||
|
|
||||||
|
let mut parser = MockUserQueryParser::new();
|
||||||
|
parser
|
||||||
|
.expect_parse_query()
|
||||||
|
.times(1)
|
||||||
|
.withf(move |query| query == text)
|
||||||
|
.returning(|_| CardInfoRequest::ByPrintedName { name: text.into() });
|
||||||
|
|
||||||
|
let mut name_fetcher = MockOriginalCardNameFetcher::new();
|
||||||
|
name_fetcher
|
||||||
|
.expect_fetch_original_card_name()
|
||||||
|
.times(1)
|
||||||
|
.withf(|info| *info == CardInfoRequest::ByPrintedName { name: text.into() })
|
||||||
|
.returning(|_| {
|
||||||
|
Box::pin(async { Ok(Some(OriginalCardName("Teferi, Hero of Dominaria".into()))) })
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut offers_fetcher = MockCardOffersFetcher::new();
|
||||||
|
offers_fetcher
|
||||||
|
.expect_fetch_card_offers()
|
||||||
|
.times(1)
|
||||||
|
.withf(|query| query.0 == "Teferi, Hero of Dominaria")
|
||||||
|
.returning(|_| {
|
||||||
|
Box::pin(async {
|
||||||
|
Ok(vec![CardOffer {
|
||||||
|
price: "4.99".into(),
|
||||||
|
set: "Dominaria".into(),
|
||||||
|
link: "link".into(),
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut formatter = MockResponseMessageFormatter::new();
|
||||||
|
formatter
|
||||||
|
.expect_format_message()
|
||||||
|
.times(1)
|
||||||
|
.withf(|message| match message {
|
||||||
|
ResponseMessage::Success {
|
||||||
|
original_name,
|
||||||
|
offers,
|
||||||
|
} => {
|
||||||
|
original_name.0 == "Teferi, Hero of Dominaria"
|
||||||
|
&& *offers
|
||||||
|
== vec![CardOffer {
|
||||||
|
price: "4.99".into(),
|
||||||
|
set: "Dominaria".into(),
|
||||||
|
link: "link".into(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
.returning(|_| FormattedResponseMessage("success".into()));
|
||||||
|
|
||||||
|
let mut sender = MockMessageSender::new();
|
||||||
|
sender
|
||||||
|
.expect_send_message()
|
||||||
|
.times(1)
|
||||||
|
.withf(|chat_id, message| *chat_id == 123 && message.0 == "success")
|
||||||
|
.returning(|_, _| Box::pin(async { Ok(()) }));
|
||||||
|
|
||||||
|
let use_case =
|
||||||
|
GetCardInfoHandler::new(parser, name_fetcher, sender, offers_fetcher, formatter);
|
||||||
|
|
||||||
|
use_case.get_card_info(123, text).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/domain.rs
Normal file
63
src/domain.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use mockall::automock;
|
||||||
|
|
||||||
|
#[automock]
|
||||||
|
pub trait UserQueryParser {
|
||||||
|
fn parse_query(&self, query: &str) -> CardInfoRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Debug)]
|
||||||
|
pub enum CardInfoRequest {
|
||||||
|
ByPrintedName { name: String },
|
||||||
|
BySetAndNumber { set: String, number: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[automock]
|
||||||
|
pub trait OriginalCardNameFetcher {
|
||||||
|
fn fetch_original_card_name(
|
||||||
|
&self,
|
||||||
|
request: &CardInfoRequest,
|
||||||
|
) -> impl Future<Output = Result<Option<OriginalCardName>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OriginalCardName(pub String);
|
||||||
|
|
||||||
|
#[automock]
|
||||||
|
pub trait CardOffersFetcher {
|
||||||
|
fn fetch_card_offers(
|
||||||
|
&self,
|
||||||
|
original_name: &OriginalCardName,
|
||||||
|
) -> impl Future<Output = Result<Vec<CardOffer>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct CardOffer {
|
||||||
|
pub set: String,
|
||||||
|
pub price: String,
|
||||||
|
pub link: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ResponseMessage {
|
||||||
|
Success {
|
||||||
|
original_name: OriginalCardName,
|
||||||
|
offers: Vec<CardOffer>,
|
||||||
|
},
|
||||||
|
CardNotFound,
|
||||||
|
ServiceUnavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[automock]
|
||||||
|
pub trait ResponseMessageFormatter {
|
||||||
|
fn format_message(&self, message: &ResponseMessage) -> FormattedResponseMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FormattedResponseMessage(pub String);
|
||||||
|
|
||||||
|
#[automock]
|
||||||
|
pub trait MessageSender {
|
||||||
|
fn send_message(
|
||||||
|
&self,
|
||||||
|
chat_id: i64,
|
||||||
|
message: &FormattedResponseMessage,
|
||||||
|
) -> impl Future<Output = Result<()>>;
|
||||||
|
}
|
||||||
5
src/infrastructure.rs
Normal file
5
src/infrastructure.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod scryfall;
|
||||||
|
pub mod starcitygames;
|
||||||
|
pub mod query;
|
||||||
|
pub mod vk;
|
||||||
|
pub mod telegram;
|
||||||
51
src/infrastructure/query.rs
Normal file
51
src/infrastructure/query.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
use crate::domain::{CardInfoRequest, UserQueryParser};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct UserQueryChatParser;
|
||||||
|
|
||||||
|
impl UserQueryChatParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserQueryParser for UserQueryChatParser {
|
||||||
|
fn parse_query(&self, query: &str) -> crate::domain::CardInfoRequest {
|
||||||
|
let parts: Vec<&str> = query.split(" ").collect();
|
||||||
|
match parts.as_slice() {
|
||||||
|
["!s", set, code] => CardInfoRequest::BySetAndNumber {
|
||||||
|
set: (*set).into(),
|
||||||
|
number: (*code).into(),
|
||||||
|
},
|
||||||
|
_ => CardInfoRequest::ByPrintedName { name: query.into() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_name_request() {
|
||||||
|
let query = "Тефери, герой доминарии";
|
||||||
|
let request: CardInfoRequest = UserQueryChatParser::new().parse_query(query);
|
||||||
|
assert_eq!(
|
||||||
|
request,
|
||||||
|
CardInfoRequest::ByPrintedName { name: query.into() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_and_code_request() {
|
||||||
|
let query = "!s GRN 1";
|
||||||
|
let request: CardInfoRequest = UserQueryChatParser::new().parse_query(query);
|
||||||
|
assert_eq!(
|
||||||
|
request,
|
||||||
|
CardInfoRequest::BySetAndNumber {
|
||||||
|
set: "GRN".into(),
|
||||||
|
number: "1".into()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/infrastructure/scryfall.rs
Normal file
160
src/infrastructure/scryfall.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::{Client, StatusCode, Url};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::domain::{CardInfoRequest, OriginalCardName, OriginalCardNameFetcher};
|
||||||
|
|
||||||
|
pub struct ScryfallOriginalCardNameFetcher {
|
||||||
|
client: Client,
|
||||||
|
url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CardResponse {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScryfallOriginalCardNameFetcher {
|
||||||
|
pub fn new(client: Client, url: Url) -> Self {
|
||||||
|
Self { client, url }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OriginalCardNameFetcher for ScryfallOriginalCardNameFetcher {
|
||||||
|
async fn fetch_original_card_name(
|
||||||
|
&self,
|
||||||
|
request: &CardInfoRequest,
|
||||||
|
) -> Result<Option<OriginalCardName>> {
|
||||||
|
let url = match request {
|
||||||
|
CardInfoRequest::ByPrintedName { name } => {
|
||||||
|
let mut url = self
|
||||||
|
.url
|
||||||
|
.join("/cards/named")
|
||||||
|
.context("failed to form scryfall named url")?;
|
||||||
|
url.query_pairs_mut().append_pair("fuzzy", name);
|
||||||
|
url
|
||||||
|
}
|
||||||
|
CardInfoRequest::BySetAndNumber { set, number } => self
|
||||||
|
.url
|
||||||
|
.join(&format!("/cards/{set}/{number}"))
|
||||||
|
.context("failed to form scryfall set+number url")?,
|
||||||
|
};
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.header("user-agent", "mtg-price-bot/1.0")
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to get card info from scryfall")?;
|
||||||
|
if res.status() == StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let response = res
|
||||||
|
.json::<CardResponse>()
|
||||||
|
.await
|
||||||
|
.context("failed to parse scryfall response")?;
|
||||||
|
Ok(Some(OriginalCardName(response.name)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search_card_by_printed_name() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let mock = server
|
||||||
|
.mock("GET", "/cards/named")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded(
|
||||||
|
"fuzzy".into(),
|
||||||
|
"Тефери, герой доминарии".into(),
|
||||||
|
))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
json!({
|
||||||
|
"name": "Teferi, Hero of Dominaria"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let url = Url::parse(server.url().as_str()).unwrap();
|
||||||
|
|
||||||
|
let name = ScryfallOriginalCardNameFetcher::new(Client::new(), url)
|
||||||
|
.fetch_original_card_name(&CardInfoRequest::ByPrintedName {
|
||||||
|
name: "Тефери, герой доминарии".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(name.unwrap().0, "Teferi, Hero of Dominaria");
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search_card_by_set_and_number() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let mock = server
|
||||||
|
.mock("GET", "/cards/GRN/123")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
json!({
|
||||||
|
"name": "Teferi, Hero of Dominaria"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let url = Url::parse(server.url().as_str()).unwrap();
|
||||||
|
|
||||||
|
let name = ScryfallOriginalCardNameFetcher::new(Client::new(), url)
|
||||||
|
.fetch_original_card_name(&CardInfoRequest::BySetAndNumber {
|
||||||
|
set: "GRN".into(),
|
||||||
|
number: "123".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(name.unwrap().0, "Teferi, Hero of Dominaria");
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search_card_not_found() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let mock = server
|
||||||
|
.mock("GET", "/cards/named")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded(
|
||||||
|
"fuzzy".into(),
|
||||||
|
"Тефери, герой доминарии".into(),
|
||||||
|
))
|
||||||
|
.with_status(404)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let url = Url::parse(server.url().as_str()).unwrap();
|
||||||
|
|
||||||
|
let result = ScryfallOriginalCardNameFetcher::new(Client::new(), url)
|
||||||
|
.fetch_original_card_name(&CardInfoRequest::ByPrintedName {
|
||||||
|
name: "Тефери, герой доминарии".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_none());
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/infrastructure/starcitygames.rs
Normal file
209
src/infrastructure/starcitygames.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::domain::{CardOffer, CardOffersFetcher, OriginalCardName};
|
||||||
|
|
||||||
|
pub struct StarCityGamesCardOffersFetcher {
|
||||||
|
client: Client,
|
||||||
|
search_url: Url,
|
||||||
|
display_url: String,
|
||||||
|
client_guid: String,
|
||||||
|
max_offers: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
#[serde(rename = "Results")]
|
||||||
|
results: Vec<ResponseResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ResponseResult {
|
||||||
|
#[serde(rename = "Document")]
|
||||||
|
document: ResponseDocument,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ResponseDocument {
|
||||||
|
filter_set: Vec<String>,
|
||||||
|
hawk_child_attributes: Vec<ResponseHawkChildAttributes>,
|
||||||
|
url_detail: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ResponseHawkChildAttributes {
|
||||||
|
calculated_price: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StarCityGamesCardOffersFetcher {
|
||||||
|
pub fn new(
|
||||||
|
client: Client,
|
||||||
|
search_url: Url,
|
||||||
|
display_url: String,
|
||||||
|
client_guid: String,
|
||||||
|
max_offers: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
search_url,
|
||||||
|
display_url,
|
||||||
|
client_guid,
|
||||||
|
max_offers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CardOffersFetcher for StarCityGamesCardOffersFetcher {
|
||||||
|
async fn fetch_card_offers(&self, original_name: &OriginalCardName) -> Result<Vec<CardOffer>> {
|
||||||
|
let url = self
|
||||||
|
.search_url
|
||||||
|
.join("/api/v2/search")
|
||||||
|
.context("failed to form starcity url")?;
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(url)
|
||||||
|
.json(&json!(
|
||||||
|
{
|
||||||
|
"FacetSelections": {
|
||||||
|
"item_display_name": [
|
||||||
|
original_name.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clientguid": self.client_guid,
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to get cards from starcity api")?;
|
||||||
|
|
||||||
|
let response: Response = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("failed to parse starcity response")?;
|
||||||
|
|
||||||
|
let offers = response
|
||||||
|
.results
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|result| {
|
||||||
|
let mut document = result.document;
|
||||||
|
let set = document.filter_set.pop()?;
|
||||||
|
let price = document
|
||||||
|
.hawk_child_attributes
|
||||||
|
.pop()?
|
||||||
|
.calculated_price
|
||||||
|
.pop()?;
|
||||||
|
let link = format!("{}{}", self.display_url, document.url_detail.pop()?);
|
||||||
|
Some(CardOffer { set, price, link })
|
||||||
|
})
|
||||||
|
.take(self.max_offers)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(offers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use rstest::rstest;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(1)]
|
||||||
|
#[case(2)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search_offers(#[case] max_offers: usize) {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/api/v2/search")
|
||||||
|
.match_body(mockito::Matcher::Json(json!({
|
||||||
|
"FacetSelections": {
|
||||||
|
"item_display_name": [
|
||||||
|
"Teferi, Hero of Dominaria"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clientguid": "guid",
|
||||||
|
})))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
json!({
|
||||||
|
"Results": [
|
||||||
|
{
|
||||||
|
"Document": {
|
||||||
|
"filter_set": [
|
||||||
|
"Dominaria (Foil)"
|
||||||
|
],
|
||||||
|
"hawk_child_attributes": [
|
||||||
|
{
|
||||||
|
"calculated_price": [
|
||||||
|
"9.99"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url_detail": [
|
||||||
|
"/teferi-hero-of-dominaria-sgl-mtg-prm-prmr_2022_002-enn/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Document": {
|
||||||
|
"filter_set": [
|
||||||
|
"Dominaria"
|
||||||
|
],
|
||||||
|
"hawk_child_attributes": [
|
||||||
|
{
|
||||||
|
"calculated_price": [
|
||||||
|
"4.99"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url_detail": [
|
||||||
|
"/teferi-hero-of-dominaria-sgl-mtg-mb2-090-enn/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let search_url = Url::parse(server.url().as_str()).unwrap();
|
||||||
|
|
||||||
|
let offers = StarCityGamesCardOffersFetcher::new(
|
||||||
|
Client::new(),
|
||||||
|
search_url,
|
||||||
|
"https://scg.com".into(),
|
||||||
|
"guid".into(),
|
||||||
|
max_offers,
|
||||||
|
)
|
||||||
|
.fetch_card_offers(&OriginalCardName("Teferi, Hero of Dominaria".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let expect_offers: Vec<CardOffer> = [
|
||||||
|
CardOffer {
|
||||||
|
set: "Dominaria (Foil)".into(),
|
||||||
|
price: "9.99".into(),
|
||||||
|
link: "https://scg.com/teferi-hero-of-dominaria-sgl-mtg-prm-prmr_2022_002-enn/"
|
||||||
|
.into(),
|
||||||
|
},
|
||||||
|
CardOffer {
|
||||||
|
set: "Dominaria".into(),
|
||||||
|
price: "4.99".into(),
|
||||||
|
link: "https://scg.com/teferi-hero-of-dominaria-sgl-mtg-mb2-090-enn/".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.take(max_offers)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(offers, expect_offers);
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/infrastructure/telegram.rs
Normal file
178
src/infrastructure/telegram.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
|
||||||
|
use crate::domain::{
|
||||||
|
FormattedResponseMessage, MessageSender, ResponseMessage, ResponseMessageFormatter,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct TelegramResponseFormatter;
|
||||||
|
|
||||||
|
impl TelegramResponseFormatter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseMessageFormatter for TelegramResponseFormatter {
|
||||||
|
fn format_message(&self, info: &ResponseMessage) -> FormattedResponseMessage {
|
||||||
|
let message = match info {
|
||||||
|
ResponseMessage::Success {
|
||||||
|
original_name,
|
||||||
|
offers,
|
||||||
|
} => {
|
||||||
|
let offers: String = offers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, offer)| {
|
||||||
|
format!(
|
||||||
|
"{}. <a href=\"{}\">{}</a>: ${}\n",
|
||||||
|
i + 1,
|
||||||
|
escape(&offer.link),
|
||||||
|
escape(&offer.set),
|
||||||
|
escape(&offer.price),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"Оригинальное название: {}\n\n{}",
|
||||||
|
escape(&original_name.0),
|
||||||
|
offers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ResponseMessage::CardNotFound => "Карта не найдена".into(),
|
||||||
|
ResponseMessage::ServiceUnavailable => "Сервис временно недоступен".into(),
|
||||||
|
};
|
||||||
|
FormattedResponseMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape(text: &str) -> String {
|
||||||
|
text.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TelegramMessageSender {
|
||||||
|
client: Client,
|
||||||
|
url: Url,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelegramMessageSender {
|
||||||
|
pub fn new(client: Client, url: Url, token: String) -> Self {
|
||||||
|
Self { client, url, token }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageSender for TelegramMessageSender {
|
||||||
|
async fn send_message(&self, chat_id: i64, message: &FormattedResponseMessage) -> Result<()> {
|
||||||
|
let url = self
|
||||||
|
.url
|
||||||
|
.join(&format!("/bot{}/sendMessage", self.token))
|
||||||
|
.context("failed to form telegram url")?;
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.post(url)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": message.0,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"link_preview_options": {
|
||||||
|
"is_disabled": true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to send telegram message")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockito::Matcher;
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use crate::domain::{CardOffer, OriginalCardName};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_formatting_normal() {
|
||||||
|
let formatter = TelegramResponseFormatter::new();
|
||||||
|
let original_name = OriginalCardName("Teferi, Hero of Dominaria".into());
|
||||||
|
let offers = vec![
|
||||||
|
CardOffer {
|
||||||
|
set: "Dominaria (Foil)".into(),
|
||||||
|
price: "9.99".into(),
|
||||||
|
link: "link1".into(),
|
||||||
|
},
|
||||||
|
CardOffer {
|
||||||
|
set: "Dominaria".into(),
|
||||||
|
price: "4.99".into(),
|
||||||
|
link: "link2".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let result = formatter.format_message(&ResponseMessage::Success {
|
||||||
|
original_name,
|
||||||
|
offers,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
result.0,
|
||||||
|
"Оригинальное название: Teferi, Hero of Dominaria\n\n1. <a href=\"link1\">Dominaria (Foil)</a>: $9.99\n2. <a href=\"link2\">Dominaria</a>: $4.99\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_formatting_escaping() {
|
||||||
|
let formatter = TelegramResponseFormatter::new();
|
||||||
|
let original_name = OriginalCardName("<&>".into());
|
||||||
|
let offers = vec![CardOffer {
|
||||||
|
set: "<&>".into(),
|
||||||
|
price: "<&>".into(),
|
||||||
|
link: "<&>".into(),
|
||||||
|
}];
|
||||||
|
let result = formatter.format_message(&ResponseMessage::Success {
|
||||||
|
original_name,
|
||||||
|
offers,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
result.0,
|
||||||
|
"Оригинальное название: <&>\n\n1. <a href=\"<&>\"><&></a>: $<&>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_message_send() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/bottoken/sendMessage")
|
||||||
|
.match_body(Matcher::Json(serde_json::json!(
|
||||||
|
{
|
||||||
|
"chat_id": 123,
|
||||||
|
"text": "not found",
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"link_preview_options": {
|
||||||
|
"is_disabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)))
|
||||||
|
.with_status(200)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let url = Url::parse(server.url().as_str()).unwrap();
|
||||||
|
|
||||||
|
let sender = TelegramMessageSender::new(Client::new(), url, "token".into());
|
||||||
|
|
||||||
|
sender
|
||||||
|
.send_message(123, &FormattedResponseMessage("not found".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
mock.assert_async().await
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/infrastructure/vk.rs
Normal file
144
src/infrastructure/vk.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
use crate::domain::{
|
||||||
|
FormattedResponseMessage, MessageSender, ResponseMessage, ResponseMessageFormatter,
|
||||||
|
};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct VkResponseFormatter;
|
||||||
|
|
||||||
|
impl VkResponseFormatter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseMessageFormatter for VkResponseFormatter {
|
||||||
|
fn format_message(&self, message: &ResponseMessage) -> FormattedResponseMessage {
|
||||||
|
let result = match message {
|
||||||
|
ResponseMessage::Success {
|
||||||
|
original_name,
|
||||||
|
offers,
|
||||||
|
} => {
|
||||||
|
let offers: String = offers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, offer)| {
|
||||||
|
format!(
|
||||||
|
"{}. {}: ${}\n{}\n",
|
||||||
|
i + 1,
|
||||||
|
offer.set,
|
||||||
|
offer.price,
|
||||||
|
offer.link
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("Оригинальное название: {}\n\n{}", original_name.0, offers)
|
||||||
|
}
|
||||||
|
ResponseMessage::CardNotFound => "Карта не найдена.".into(),
|
||||||
|
ResponseMessage::ServiceUnavailable => "Сервис временно недоступен.".into(),
|
||||||
|
};
|
||||||
|
FormattedResponseMessage(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VkMessageSender {
|
||||||
|
client: Client,
|
||||||
|
url: Url,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VkMessageSender {
|
||||||
|
pub fn new(client: Client, url: Url, token: String) -> Self {
|
||||||
|
Self { client, url, token }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageSender for VkMessageSender {
|
||||||
|
async fn send_message(&self, chat_id: i64, message: &FormattedResponseMessage) -> Result<()> {
|
||||||
|
let mut url = self
|
||||||
|
.url
|
||||||
|
.join("/method/messages.send")
|
||||||
|
.context("failed to form vk url")?;
|
||||||
|
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("user_id", chat_id.to_string().as_str())
|
||||||
|
.append_pair("random_id", "0")
|
||||||
|
.append_pair("message", &message.0)
|
||||||
|
.append_pair("access_token", &self.token)
|
||||||
|
.append_pair("v", "5.199");
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.post(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to send vk message")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockito::Matcher;
|
||||||
|
|
||||||
|
use crate::domain::{CardOffer, OriginalCardName};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format() {
|
||||||
|
let formatter = VkResponseFormatter::new();
|
||||||
|
let original_name = OriginalCardName("Teferi, Hero of Dominaria".into());
|
||||||
|
let offers = vec![
|
||||||
|
CardOffer {
|
||||||
|
set: "Dominaria (Foil)".into(),
|
||||||
|
price: "9.99".into(),
|
||||||
|
link: "link1".into(),
|
||||||
|
},
|
||||||
|
CardOffer {
|
||||||
|
set: "Dominaria".into(),
|
||||||
|
price: "4.99".into(),
|
||||||
|
link: "link2".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let result = formatter.format_message(&ResponseMessage::Success {
|
||||||
|
original_name,
|
||||||
|
offers,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.0,
|
||||||
|
"Оригинальное название: Teferi, Hero of Dominaria\n\n1. Dominaria (Foil): $9.99\nlink1\n2. Dominaria: $4.99\nlink2\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_send_message() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/method/messages.send")
|
||||||
|
.match_query(Matcher::AllOf(vec![
|
||||||
|
Matcher::UrlEncoded("user_id".into(), "123".parse().unwrap()),
|
||||||
|
Matcher::UrlEncoded("random_id".into(), "0".parse().unwrap()),
|
||||||
|
Matcher::UrlEncoded("message".into(), "not found".parse().unwrap()),
|
||||||
|
Matcher::UrlEncoded("access_token".into(), "token".parse().unwrap()),
|
||||||
|
Matcher::UrlEncoded("v".into(), "5.199".parse().unwrap()),
|
||||||
|
]))
|
||||||
|
.with_status(200)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let url = Url::parse(server.url().as_str()).unwrap();
|
||||||
|
|
||||||
|
let sender = VkMessageSender::new(Client::new(), url, "token".into());
|
||||||
|
|
||||||
|
sender
|
||||||
|
.send_message(123, &FormattedResponseMessage("not found".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
mock.assert_async().await
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/lib.rs
Normal file
4
src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod domain;
|
||||||
|
pub mod application;
|
||||||
|
pub mod infrastructure;
|
||||||
|
pub mod presentation;
|
||||||
191
src/main.rs
Normal file
191
src/main.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::{env, sync::Arc};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
routing::{MethodRouter, post},
|
||||||
|
};
|
||||||
|
use mtg_price_bot::{
|
||||||
|
application::GetCardInfoHandler,
|
||||||
|
infrastructure::{
|
||||||
|
query::UserQueryChatParser,
|
||||||
|
scryfall::ScryfallOriginalCardNameFetcher,
|
||||||
|
starcitygames::StarCityGamesCardOffersFetcher,
|
||||||
|
telegram::{TelegramMessageSender, TelegramResponseFormatter},
|
||||||
|
vk::{VkMessageSender, VkResponseFormatter},
|
||||||
|
},
|
||||||
|
presentation::{
|
||||||
|
telegram::{TelegramState, TelegramWebhookSetter, handle_telegram_message},
|
||||||
|
vk::{VkState, handle_vk_message},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt().init();
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/tg", get_telegram_handler(&config, client.clone()).await?)
|
||||||
|
.route("/vk", get_vk_handler(&config, client));
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(config.bind_address).await?;
|
||||||
|
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_telegram_handler(config: &Config, client: Client) -> Result<MethodRouter> {
|
||||||
|
let setter = TelegramWebhookSetter::new(
|
||||||
|
client.clone(),
|
||||||
|
config.telegram.url.clone(),
|
||||||
|
config.telegram.secret.clone(),
|
||||||
|
config.telegram.token.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
setter.set_webhook(&config.telegram.callback_url).await?;
|
||||||
|
|
||||||
|
let parser = UserQueryChatParser::new();
|
||||||
|
let name_fetcher =
|
||||||
|
ScryfallOriginalCardNameFetcher::new(client.clone(), config.scryfall_url.clone());
|
||||||
|
let formatter = TelegramResponseFormatter::new();
|
||||||
|
let telegram_sender = TelegramMessageSender::new(
|
||||||
|
client.clone(),
|
||||||
|
config.telegram.url.clone(),
|
||||||
|
config.telegram.token.clone(),
|
||||||
|
);
|
||||||
|
let offers_fetcher = get_star_city_games_fetcher(&config.star_city_games, client.clone());
|
||||||
|
|
||||||
|
let handler = GetCardInfoHandler::new(
|
||||||
|
parser,
|
||||||
|
name_fetcher,
|
||||||
|
telegram_sender,
|
||||||
|
offers_fetcher,
|
||||||
|
formatter,
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = TelegramState {
|
||||||
|
secret: config.telegram.secret.clone(),
|
||||||
|
handler,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(post(handle_telegram_message).with_state(Arc::new(state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_vk_handler(config: &Config, client: Client) -> MethodRouter {
|
||||||
|
let parser = UserQueryChatParser::new();
|
||||||
|
let name_fetcher =
|
||||||
|
ScryfallOriginalCardNameFetcher::new(client.clone(), config.scryfall_url.clone());
|
||||||
|
let formatter = VkResponseFormatter::new();
|
||||||
|
let sender = VkMessageSender::new(
|
||||||
|
client.clone(),
|
||||||
|
config.vk.url.clone(),
|
||||||
|
config.vk.token.clone(),
|
||||||
|
);
|
||||||
|
let offers_fetcher = get_star_city_games_fetcher(&config.star_city_games, client.clone());
|
||||||
|
|
||||||
|
let handler = GetCardInfoHandler::new(parser, name_fetcher, sender, offers_fetcher, formatter);
|
||||||
|
|
||||||
|
let state = VkState {
|
||||||
|
secret: config.vk.secret.clone(),
|
||||||
|
handler,
|
||||||
|
confirmation_code: config.vk.confirmation.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
post(handle_vk_message).with_state(Arc::new(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_star_city_games_fetcher(
|
||||||
|
config: &StarCityGamesConfig,
|
||||||
|
client: Client,
|
||||||
|
) -> StarCityGamesCardOffersFetcher {
|
||||||
|
StarCityGamesCardOffersFetcher::new(
|
||||||
|
client,
|
||||||
|
config.search_url.clone(),
|
||||||
|
config.display_url.clone(),
|
||||||
|
config.client_guid.clone(),
|
||||||
|
config.max_offers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
telegram: TelegramConfig,
|
||||||
|
vk: VkConfig,
|
||||||
|
star_city_games: StarCityGamesConfig,
|
||||||
|
scryfall_url: Url,
|
||||||
|
bind_address: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
fn from_env() -> Result<Self> {
|
||||||
|
let config = Self {
|
||||||
|
telegram: TelegramConfig::from_env()?,
|
||||||
|
vk: VkConfig::from_env()?,
|
||||||
|
star_city_games: StarCityGamesConfig::from_env()?,
|
||||||
|
scryfall_url: Url::parse(&env::var("SCRYFALL_URL")?)?,
|
||||||
|
bind_address: env::var("BIND_ADDRESS")?,
|
||||||
|
};
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TelegramConfig {
|
||||||
|
url: Url,
|
||||||
|
secret: String,
|
||||||
|
token: String,
|
||||||
|
callback_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelegramConfig {
|
||||||
|
fn from_env() -> Result<Self> {
|
||||||
|
let config = Self {
|
||||||
|
url: Url::parse(&env::var("TELEGRAM_API_URL")?)?,
|
||||||
|
secret: env::var("TELEGRAM_SECRET")?,
|
||||||
|
token: env::var("TELEGRAM_TOKEN")?,
|
||||||
|
callback_url: env::var("TELEGRAM_CALLBACK_URL")?,
|
||||||
|
};
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VkConfig {
|
||||||
|
url: Url,
|
||||||
|
token: String,
|
||||||
|
secret: String,
|
||||||
|
confirmation: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VkConfig {
|
||||||
|
fn from_env() -> Result<Self> {
|
||||||
|
let config = Self {
|
||||||
|
url: Url::parse(&env::var("VK_API_URL")?)?,
|
||||||
|
token: env::var("VK_TOKEN")?,
|
||||||
|
secret: env::var("VK_SECRET")?,
|
||||||
|
confirmation: env::var("VK_CONFIRMATION_CODE")?,
|
||||||
|
};
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StarCityGamesConfig {
|
||||||
|
search_url: Url,
|
||||||
|
client_guid: String,
|
||||||
|
display_url: String,
|
||||||
|
max_offers: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StarCityGamesConfig {
|
||||||
|
fn from_env() -> Result<Self> {
|
||||||
|
let config = Self {
|
||||||
|
search_url: Url::parse(&env::var("SCG_SEARCH_URL")?)?,
|
||||||
|
client_guid: env::var("SCG_CLIENT_GUID")?,
|
||||||
|
display_url: env::var("SCG_DISPLAY_URL")?,
|
||||||
|
max_offers: env::var("SCG_MAX_OFFERS")?.parse()?,
|
||||||
|
};
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/presentation.rs
Normal file
2
src/presentation.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod vk;
|
||||||
|
pub mod telegram;
|
||||||
182
src/presentation/telegram.rs
Normal file
182
src/presentation/telegram.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::{Json, extract::State, http::HeaderMap};
|
||||||
|
use reqwest::{Client, StatusCode, Url};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::application::GetCardInfoUseCase;
|
||||||
|
|
||||||
|
pub struct TelegramState<T: GetCardInfoUseCase> {
|
||||||
|
pub secret: String,
|
||||||
|
pub handler: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TelegramUpdate {
|
||||||
|
message: Option<TelegramMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TelegramMessage {
|
||||||
|
chat: TelegramChat,
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TelegramChat {
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_telegram_message<T: GetCardInfoUseCase>(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(state): State<Arc<TelegramState<T>>>,
|
||||||
|
Json(body): Json<TelegramUpdate>,
|
||||||
|
) -> StatusCode {
|
||||||
|
let secret_header = match headers.get("X-Telegram-Bot-Api-Secret-Token") {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return StatusCode::FORBIDDEN,
|
||||||
|
};
|
||||||
|
if secret_header.as_bytes() != state.secret.as_bytes() {
|
||||||
|
return StatusCode::FORBIDDEN;
|
||||||
|
}
|
||||||
|
let (chat_id, message) = match body {
|
||||||
|
TelegramUpdate {
|
||||||
|
message:
|
||||||
|
Some(TelegramMessage {
|
||||||
|
chat: TelegramChat { id },
|
||||||
|
text: Some(text),
|
||||||
|
}),
|
||||||
|
} => (id, text),
|
||||||
|
_ => return StatusCode::OK,
|
||||||
|
};
|
||||||
|
let res = state.handler.get_card_info(chat_id, &message).await;
|
||||||
|
if let Err(err) = res {
|
||||||
|
tracing::error!(?err);
|
||||||
|
}
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TelegramWebhookSetter {
|
||||||
|
client: Client,
|
||||||
|
url: Url,
|
||||||
|
secret: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelegramWebhookSetter {
|
||||||
|
pub fn new(client: Client, url: Url, secret: String, token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
url,
|
||||||
|
secret,
|
||||||
|
token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_webhook(&self, url: &str) -> Result<()> {
|
||||||
|
let mut api_url = self.url.join(&format!("/bot{}/setWebhook", self.token))?;
|
||||||
|
|
||||||
|
api_url
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("url", url)
|
||||||
|
.append_pair("secret_token", &self.secret);
|
||||||
|
|
||||||
|
self.client.post(api_url).send().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use mockito::Matcher;
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
|
||||||
|
use crate::application::MockGetCardInfoUseCase;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_telegram_message_invalid_secret() {
|
||||||
|
let state = TelegramState {
|
||||||
|
secret: "secret".into(),
|
||||||
|
handler: MockGetCardInfoUseCase::new(),
|
||||||
|
};
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append(
|
||||||
|
"X-Telegram-Bot-Api-Secret-Token",
|
||||||
|
"invalid".parse().unwrap(),
|
||||||
|
);
|
||||||
|
let code = handle_telegram_message(
|
||||||
|
headers,
|
||||||
|
State(Arc::new(state)),
|
||||||
|
Json(TelegramUpdate { message: None }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(code, StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_telegram_message_update_without_message() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append("X-Telegram-Bot-Api-Secret-Token", "secret".parse().unwrap());
|
||||||
|
let state = TelegramState {
|
||||||
|
secret: "secret".into(),
|
||||||
|
handler: MockGetCardInfoUseCase::new(),
|
||||||
|
};
|
||||||
|
let update = TelegramUpdate { message: None };
|
||||||
|
let code = handle_telegram_message(headers, State(Arc::new(state)), Json(update)).await;
|
||||||
|
assert_eq!(code, StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_telegram_message_success() {
|
||||||
|
let mut handler = MockGetCardInfoUseCase::new();
|
||||||
|
handler
|
||||||
|
.expect_get_card_info()
|
||||||
|
.times(1)
|
||||||
|
.withf(|chat_id, message| *chat_id == 1 && message == "text")
|
||||||
|
.returning(|_, _| Box::pin(async { Ok(()) }));
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append("X-Telegram-Bot-Api-Secret-Token", "secret".parse().unwrap());
|
||||||
|
let state = TelegramState {
|
||||||
|
secret: "secret".into(),
|
||||||
|
handler,
|
||||||
|
};
|
||||||
|
let update = TelegramUpdate {
|
||||||
|
message: Some(TelegramMessage {
|
||||||
|
chat: TelegramChat { id: 1 },
|
||||||
|
text: Some("text".into()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let code = handle_telegram_message(headers, State(Arc::new(state)), Json(update)).await;
|
||||||
|
assert_eq!(code, StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_set_telegram_webhook() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/bottoken/setWebhook")
|
||||||
|
.match_query(Matcher::AllOf(vec![
|
||||||
|
Matcher::UrlEncoded(
|
||||||
|
"url".into(),
|
||||||
|
"https://mtg-bot.flygrounder.ru/tg".parse().unwrap(),
|
||||||
|
),
|
||||||
|
Matcher::UrlEncoded("secret_token".into(), "secret".parse().unwrap()),
|
||||||
|
]))
|
||||||
|
.with_status(200)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let url = Url::parse(server.url().as_str()).unwrap();
|
||||||
|
let setter = TelegramWebhookSetter::new(Client::new(), url, "secret".into(), "token".into());
|
||||||
|
setter.set_webhook("https://mtg-bot.flygrounder.ru/tg").await.unwrap();
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/presentation/vk.rs
Normal file
162
src/presentation/vk.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{Json, extract::State};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::application::GetCardInfoUseCase;
|
||||||
|
pub struct VkState<V: GetCardInfoUseCase> {
|
||||||
|
pub secret: String,
|
||||||
|
pub handler: V,
|
||||||
|
pub confirmation_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum Request {
|
||||||
|
#[serde(rename = "message_new")]
|
||||||
|
MessageNew(MessageNew),
|
||||||
|
#[serde(rename = "confirmation")]
|
||||||
|
Confirmation(Confirmation),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Confirmation {
|
||||||
|
secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MessageNew {
|
||||||
|
object: Object,
|
||||||
|
secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Object {
|
||||||
|
message: Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Message {
|
||||||
|
from_id: i64,
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_vk_message<V: GetCardInfoUseCase>(
|
||||||
|
State(state): State<Arc<VkState<V>>>,
|
||||||
|
Json(request): Json<Request>,
|
||||||
|
) -> Result<String, StatusCode> {
|
||||||
|
match request {
|
||||||
|
Request::MessageNew(request) => {
|
||||||
|
if request.secret != state.secret {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
let message = request.object.message;
|
||||||
|
let result = state
|
||||||
|
.handler
|
||||||
|
.get_card_info(message.from_id, &message.text)
|
||||||
|
.await;
|
||||||
|
if let Err(err) = result {
|
||||||
|
tracing::error!(?err);
|
||||||
|
}
|
||||||
|
Ok("ok".into())
|
||||||
|
}
|
||||||
|
Request::Confirmation(request) => {
|
||||||
|
if request.secret != state.secret {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Ok(state.confirmation_code.clone())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::application::MockGetCardInfoUseCase;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_vk_message_invalid_secret() {
|
||||||
|
let state = VkState {
|
||||||
|
secret: "secret".into(),
|
||||||
|
handler: MockGetCardInfoUseCase::new(),
|
||||||
|
confirmation_code: "confirmation".into(),
|
||||||
|
};
|
||||||
|
let request = Request::MessageNew(MessageNew {
|
||||||
|
secret: "invalid".into(),
|
||||||
|
object: Object {
|
||||||
|
message: Message {
|
||||||
|
from_id: 1,
|
||||||
|
text: "text".into(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let code = handle_vk_message(State(Arc::new(state)), Json(request))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(code, StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_vk_message_valid_secret() {
|
||||||
|
let mut handler = MockGetCardInfoUseCase::new();
|
||||||
|
handler
|
||||||
|
.expect_get_card_info()
|
||||||
|
.times(1)
|
||||||
|
.withf(|chat_id, message| *chat_id == 1 && message == "text")
|
||||||
|
.returning(|_, _| Box::pin(async { Ok(()) }));
|
||||||
|
|
||||||
|
let state = VkState {
|
||||||
|
secret: "secret".into(),
|
||||||
|
handler,
|
||||||
|
confirmation_code: "confirmation".into(),
|
||||||
|
};
|
||||||
|
let request = Request::MessageNew(MessageNew {
|
||||||
|
secret: "secret".into(),
|
||||||
|
object: Object {
|
||||||
|
message: Message {
|
||||||
|
from_id: 1,
|
||||||
|
text: "text".into(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let result = handle_vk_message(State(Arc::new(state)), Json(request))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_confirmation_invalid_secret() {
|
||||||
|
let request = Request::Confirmation(Confirmation {
|
||||||
|
secret: "invalid".into(),
|
||||||
|
});
|
||||||
|
let state = VkState {
|
||||||
|
secret: "secret".into(),
|
||||||
|
handler: MockGetCardInfoUseCase::new(),
|
||||||
|
confirmation_code: "confirmation".into(),
|
||||||
|
};
|
||||||
|
let code = handle_vk_message(State(Arc::new(state)), Json(request))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(code, StatusCode::FORBIDDEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_confirmation_success() {
|
||||||
|
let request = Request::Confirmation(Confirmation {
|
||||||
|
secret: "secret".into(),
|
||||||
|
});
|
||||||
|
let state = VkState {
|
||||||
|
secret: "secret".into(),
|
||||||
|
handler: MockGetCardInfoUseCase::new(),
|
||||||
|
confirmation_code: "confirmation".into(),
|
||||||
|
};
|
||||||
|
let confirmation = handle_vk_message(State(Arc::new(state)), Json(request))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(confirmation, "confirmation")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue