Bypass portal authentication, implement full search parameter queries
This commit is contained in:
parent
8e8039df83
commit
8bd6e2995f
7 changed files with 303 additions and 51 deletions
|
@ -21,6 +21,9 @@ serde_json = "^1"
|
|||
reqwest = { version = "^0.11", features = ["json"], optional = true}
|
||||
url = "^2.2"
|
||||
ureq = { version = "^2", features = ["json"], optional = true}
|
||||
cookie_store = { version = "0.16", optional = true}
|
||||
cookie = { version = "0.16", optional = true}
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
base64 = "^0.13"
|
||||
num_enum = "^0.5"
|
||||
chrono = {version = "^0.4", optional = true}
|
||||
|
@ -36,10 +39,11 @@ cgmath = {version = "^0.18", optional = true}
|
|||
tokio = { version = "1.4.0", features = ["macros"]}
|
||||
|
||||
[features]
|
||||
all = ["simple", "robocraft", "cardlife", "techblox", "convert"]
|
||||
all = ["simple", "robocraft", "cardlife", "techblox", "convert", "robocraft2"]
|
||||
default = ["all"]
|
||||
simple = ["ureq"]
|
||||
robocraft = ["reqwest", "ureq"]
|
||||
cardlife = ["reqwest"]
|
||||
techblox = ["chrono", "highhash", "half", "libfj_parsable_macro_derive"]
|
||||
convert = ["obj", "genmesh", "cgmath"]
|
||||
robocraft2 = ["reqwest", "reqwest/cookies", "async-trait"]
|
||||
|
|
|
@ -13,5 +13,5 @@ pub mod robocraft_simple;
|
|||
pub mod techblox;
|
||||
#[cfg(feature = "convert")]
|
||||
pub mod convert;
|
||||
#[cfg(feature = "robocraft")]
|
||||
#[cfg(feature = "robocraft2")]
|
||||
pub mod robocraft2;
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
use std::sync::Mutex;
|
||||
|
||||
use reqwest::{Client, Error};
|
||||
use url::{Url};
|
||||
|
||||
use crate::robocraft::{ITokenProvider, DefaultTokenProvider};
|
||||
use crate::robocraft2::{SearchPayload, SearchResponse};
|
||||
use crate::robocraft2::{SearchPayload, SearchResponse, ITokenProvider};
|
||||
|
||||
/// Community Factory Robot 2 root URL
|
||||
pub const FACTORY_DOMAIN: &str = "https://factory.production.robocraft2.com";
|
||||
|
@ -10,23 +11,23 @@ pub const FACTORY_DOMAIN: &str = "https://factory.production.robocraft2.com";
|
|||
/// CRF API implementation
|
||||
pub struct FactoryAPI {
|
||||
client: Client,
|
||||
token: Box<dyn ITokenProvider>,
|
||||
token: Mutex<Box<dyn ITokenProvider>>,
|
||||
}
|
||||
|
||||
impl FactoryAPI {
|
||||
/// Create a new instance, using `DefaultTokenProvider`.
|
||||
/*/// Create a new instance, using `DefaultTokenProvider`.
|
||||
pub fn new() -> FactoryAPI {
|
||||
FactoryAPI {
|
||||
client: Client::new(),
|
||||
token: Box::new(DefaultTokenProvider{}),
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
/// Create a new instance using the provided token provider.
|
||||
pub fn with_auth(token_provider: Box<dyn ITokenProvider>) -> FactoryAPI {
|
||||
FactoryAPI {
|
||||
client: Client::new(),
|
||||
token: token_provider,
|
||||
token: Mutex::new(token_provider),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -34,19 +35,63 @@ impl FactoryAPI {
|
|||
///
|
||||
/// For searching, use `list_builder()` instead.
|
||||
pub async fn list(&self) -> Result<SearchResponse, Error> {
|
||||
let url = Url::parse(FACTORY_DOMAIN)
|
||||
self.search(SearchPayload::default()).await
|
||||
}
|
||||
|
||||
pub async fn search(&self, params: SearchPayload) -> Result<SearchResponse, Error> {
|
||||
let mut url = Url::parse(FACTORY_DOMAIN)
|
||||
.unwrap()
|
||||
.join("/v1/foundry/search")
|
||||
.unwrap();
|
||||
if let Some(text) = ¶ms.text {
|
||||
url.query_pairs_mut().append_pair("text", text);
|
||||
}
|
||||
if let Some(base_minimum_cpu) = params.base_minimum_cpu {
|
||||
url.query_pairs_mut().append_pair("baseCpuMinimum", &base_minimum_cpu.to_string());
|
||||
}
|
||||
if let Some(base_maximum_cpu) = ¶ms.base_maximum_cpu {
|
||||
url.query_pairs_mut().append_pair("baseCpuMaximum", &base_maximum_cpu.to_string());
|
||||
}
|
||||
if let Some(x) = ¶ms.weapon_minimum_cpu {
|
||||
url.query_pairs_mut().append_pair("weaponCpuMinimum", &x.to_string());
|
||||
}
|
||||
if let Some(x) = ¶ms.weapon_maximum_cpu {
|
||||
url.query_pairs_mut().append_pair("weaponCpuMaximum", &x.to_string());
|
||||
}
|
||||
if let Some(x) = ¶ms.cosmetic_minimum_cpu {
|
||||
url.query_pairs_mut().append_pair("cosmeticCpuMinimum", &x.to_string());
|
||||
}
|
||||
if let Some(x) = ¶ms.cosmetic_maximum_cpu {
|
||||
url.query_pairs_mut().append_pair("cosmeticCpuMaximum", &x.to_string());
|
||||
}
|
||||
if let Some(x) = ¶ms.cluster_minimum {
|
||||
url.query_pairs_mut().append_pair("clusterMinimum", &x.to_string());
|
||||
}
|
||||
if let Some(x) = ¶ms.cluster_maximum {
|
||||
url.query_pairs_mut().append_pair("clusterMaximum", &x.to_string());
|
||||
}
|
||||
if let Some(x) = ¶ms.date_minimum {
|
||||
url.query_pairs_mut().append_pair("dateMinimum", x);
|
||||
}
|
||||
if let Some(x) = ¶ms.date_maximum {
|
||||
url.query_pairs_mut().append_pair("dateMaximum", x);
|
||||
}
|
||||
if let Some(x) = ¶ms.creator_id {
|
||||
url.query_pairs_mut().append_pair("creatorId", x);
|
||||
}
|
||||
if let Some(x) = ¶ms.page {
|
||||
url.query_pairs_mut().append_pair("page", &x.to_string());
|
||||
}
|
||||
if let Some(x) = ¶ms.count {
|
||||
url.query_pairs_mut().append_pair("count", &x.to_string());
|
||||
}
|
||||
url.query_pairs_mut().append_pair("sortBy", ¶ms.sort_by);
|
||||
url.query_pairs_mut().append_pair("orderBy", ¶ms.order_by);
|
||||
let mut request_builder = self.client.get(url);
|
||||
if let Ok(token) = self.token.token() {
|
||||
if let Ok(token) = self.token.lock().unwrap().token().await {
|
||||
request_builder = request_builder.header("Authorization", "Bearer ".to_owned() + &token);
|
||||
}
|
||||
let result = request_builder.send().await?;
|
||||
result.json::<SearchResponse>().await
|
||||
}
|
||||
|
||||
async fn search(&self, params: SearchPayload) -> Result<SearchResponse, Error> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,4 +8,4 @@ mod factory_json;
|
|||
pub use factory_json::{SearchPayload, SearchResponse, SearchResponseItem, RobotInfo, RobotPrice};
|
||||
|
||||
mod portal;
|
||||
pub use self::portal::PortalTokenProvider;
|
||||
pub use self::portal::{PortalTokenProvider, AccountInfo, PortalCheckResponse, ITokenProvider};
|
||||
|
|
|
@ -1,37 +1,46 @@
|
|||
use std::sync::RwLock;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ureq::{Agent, Error};
|
||||
use serde_json::to_string;
|
||||
//use ureq::{Agent, Error, AgentBuilder};
|
||||
use reqwest::{Client, Error};
|
||||
//use cookie_store::CookieStore;
|
||||
//use url::{Url};
|
||||
use serde_json::from_slice;
|
||||
|
||||
use crate::robocraft::{ITokenProvider, account::AuthenticationResponseInfo, AccountInfo};
|
||||
/// Token generator for authenticated API endpoints
|
||||
#[async_trait::async_trait]
|
||||
pub trait ITokenProvider {
|
||||
/// Retrieve the token to use
|
||||
async fn token(&mut self) -> Result<String, ()>;
|
||||
}
|
||||
|
||||
/// Token provider for an existing Freejam account, authenticated through the web browser portal.
|
||||
///
|
||||
/// Steam and Epic accounts are not supported.
|
||||
pub struct PortalTokenProvider {
|
||||
/// Login token
|
||||
token: RwLock<ProgressionLoginResponse>,
|
||||
token: ProgressionLoginResponse,
|
||||
/// User info token
|
||||
jwt: AuthenticationResponseInfo,
|
||||
jwt: PortalCheckResponse,
|
||||
/// Ureq HTTP client
|
||||
client: Agent,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl PortalTokenProvider {
|
||||
pub fn portal() -> Result<Self, Error> {
|
||||
Self::target("Techblox".to_owned())
|
||||
/// Login through the web browser portal
|
||||
pub async fn portal() -> Result<Self, Error> {
|
||||
Self::target("Techblox".to_owned()).await
|
||||
}
|
||||
|
||||
pub fn target(value: String) -> Result<Self, Error> {
|
||||
let client = Agent::new();
|
||||
/// Login through the portal with a custom target value
|
||||
pub async fn target(value: String) -> Result<Self, Error> {
|
||||
let client = Client::new();
|
||||
let payload = PortalStartPayload {
|
||||
target: value,
|
||||
};
|
||||
let start_response = client.post("https://account.freejamgames.com/api/authenticate/portal/start")
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&to_string(&payload).unwrap())?;
|
||||
let start_res = start_response.into_json::<PortalStartResponse>()?;
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send().await?;
|
||||
let start_res = start_response.json::<PortalStartResponse>().await?;
|
||||
|
||||
println!("GO TO https://account.freejamgames.com/login?theme=rc2&redirect_url=portal?theme=rc2%26portalToken={}", start_res.token);
|
||||
|
||||
|
@ -39,42 +48,117 @@ impl PortalTokenProvider {
|
|||
token: start_res.token,
|
||||
};
|
||||
let mut check_response = client.post("https://account.freejamgames.com/api/authenticate/portal/check")
|
||||
.set("Content-Type", "application/json")
|
||||
.send_json(&payload)?;
|
||||
//.send_string(&to_string(&payload).unwrap())?;
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send().await?;
|
||||
let mut auth_complete = check_response.status() == 200;
|
||||
while !auth_complete {
|
||||
check_response = client.post("https://account.freejamgames.com/api/authenticate/portal/check")
|
||||
.set("Content-Type", "application/json")
|
||||
.send_json(&payload)?;
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send().await?;
|
||||
auth_complete = check_response.status() == 200;
|
||||
}
|
||||
let check_res = check_response.into_json::<AuthenticationResponseInfo>()?;
|
||||
let check_res = check_response.json::<PortalCheckResponse>().await?;
|
||||
|
||||
// login with token we just got
|
||||
Self::login_internal(check_res, client).await
|
||||
}
|
||||
|
||||
pub async fn with_email(email: &str, password: &str) -> Result<Self, Error> {
|
||||
let client = Client::new();
|
||||
let payload = AuthenticationEmailPayload {
|
||||
email_address: email.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
let response = client.post("https://account.freejamgames.com/api/authenticate/email/web")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send().await?;
|
||||
let json_res = response.json::<AuthenticationResponseInfo>().await?;
|
||||
Self::auto_portal(client, "Techblox".to_owned(), json_res.token).await
|
||||
}
|
||||
|
||||
pub async fn with_username(username: &str, password: &str) -> Result<Self, Error> {
|
||||
let client = Client::new();
|
||||
let payload = AuthenticationUsernamePayload {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
let response = client.post("https://account.freejamgames.com/api/authenticate/displayname/web")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send().await?;
|
||||
let json_res = response.json::<AuthenticationResponseInfo>().await?;
|
||||
Self::auto_portal(client, "Techblox".to_owned(), json_res.token).await
|
||||
}
|
||||
|
||||
/// Automatically validate portal
|
||||
async fn auto_portal(client: Client, value: String, token: String) -> Result<Self, Error> {
|
||||
let payload = PortalStartPayload {
|
||||
target: value,
|
||||
};
|
||||
let start_response = client.post("https://account.freejamgames.com/api/authenticate/portal/start")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send().await?;
|
||||
let start_res = start_response.json::<PortalStartResponse>().await?;
|
||||
|
||||
let payload = PortalCheckPayload {
|
||||
token: start_res.token,
|
||||
};
|
||||
|
||||
let _assign_response = client.post("https://account.freejamgames.com/api/authenticate/portal/assign")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Web ".to_owned() + &token)
|
||||
.json(&payload)
|
||||
.send().await?;
|
||||
|
||||
let check_response = client.post("https://account.freejamgames.com/api/authenticate/portal/check")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send().await?;
|
||||
let check_res = check_response.json::<PortalCheckResponse>().await?;
|
||||
|
||||
// login with token we just got
|
||||
Self::login_internal(check_res, client).await
|
||||
}
|
||||
|
||||
async fn login_internal(token_data: PortalCheckResponse, client: Client) -> Result<Self, Error> {
|
||||
let payload = ProgressionLoginPayload {
|
||||
token: check_res.token.clone(),
|
||||
token: token_data.token.clone(),
|
||||
};
|
||||
let progress_response = client.post("https://progression.production.robocraft2.com/login/fj")
|
||||
.set("Content-Type", "application/json")
|
||||
.send_json(&payload)?;
|
||||
let progress_res = progress_response.into_json::<ProgressionLoginResponse>()?;
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send().await?;
|
||||
let progress_res = progress_response.json::<ProgressionLoginResponse>().await?;
|
||||
Ok(Self {
|
||||
token: RwLock::new(progress_res),
|
||||
jwt: check_res,
|
||||
token: progress_res,
|
||||
jwt: token_data,
|
||||
client: client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Login using the portal token data from a previous portal authentication
|
||||
pub async fn login(token_data: PortalCheckResponse) -> Result<Self, Error> {
|
||||
Self::login_internal(token_data, Client::new()).await
|
||||
}
|
||||
|
||||
pub fn get_account_info(&self) -> Result<AccountInfo, Error> {
|
||||
Ok(self.jwt.decode_jwt_data())
|
||||
}
|
||||
|
||||
pub fn token_data(&self) -> &'_ PortalCheckResponse {
|
||||
&self.jwt
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ITokenProvider for PortalTokenProvider {
|
||||
fn token(&self) -> Result<String, ()> {
|
||||
async fn token(&mut self) -> Result<String, ()> {
|
||||
// TODO re-authenticate when expired
|
||||
if let Some(token) = self.token.read().map_err(|_| ())?.token.clone() {
|
||||
if let Some(token) = self.token.token.clone() {
|
||||
Ok(token)
|
||||
} else {
|
||||
Err(())
|
||||
|
@ -82,6 +166,32 @@ impl ITokenProvider for PortalTokenProvider {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct AuthenticationEmailPayload {
|
||||
#[serde(rename = "EmailAddress")]
|
||||
pub email_address: String,
|
||||
#[serde(rename = "Password")]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct AuthenticationUsernamePayload {
|
||||
#[serde(rename = "DisplayName")]
|
||||
pub username: String,
|
||||
#[serde(rename = "Password")]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
pub(crate) struct AuthenticationResponseInfo {
|
||||
#[serde(rename = "Token")]
|
||||
pub token: String,
|
||||
#[serde(rename = "RefreshToken")]
|
||||
pub refresh_token: String,
|
||||
#[serde(rename = "RefreshTokenExpiry")]
|
||||
pub refresh_token_expiry: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct PortalStartPayload {
|
||||
#[serde(rename = "Target")]
|
||||
|
@ -100,6 +210,27 @@ pub(crate) struct PortalCheckPayload {
|
|||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
pub struct PortalCheckResponse {
|
||||
#[serde(rename = "Token")]
|
||||
pub token: String,
|
||||
#[serde(rename = "RefreshToken")]
|
||||
pub refresh_token: String,
|
||||
#[serde(rename = "RefreshTokenExpiry")]
|
||||
pub refresh_token_expiry: String,
|
||||
}
|
||||
|
||||
impl PortalCheckResponse {
|
||||
pub fn decode_jwt_data(&self) -> AccountInfo {
|
||||
// Refer to https://jwt.io/
|
||||
// header is before dot, signature is after dot.
|
||||
// data is sandwiched in the middle, and it's all we care about
|
||||
let data = self.token.split(".").collect::<Vec<&str>>()[1];
|
||||
let data_vec = base64::decode(data).unwrap();
|
||||
from_slice::<AccountInfo>(&data_vec).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct ProgressionLoginPayload {
|
||||
#[serde(rename = "token")]
|
||||
|
@ -117,3 +248,59 @@ pub(crate) struct ProgressionLoginResponse {
|
|||
#[serde(rename = "serverToken")]
|
||||
pub server_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Robocraft2 account information.
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct AccountInfo {
|
||||
/// User's public ID
|
||||
#[serde(rename = "PublicId")]
|
||||
pub public_id: String,
|
||||
/// Account display name
|
||||
#[serde(rename = "DisplayName")]
|
||||
pub display_name: String,
|
||||
/// Account GUID, or display name for older accounts
|
||||
#[serde(rename = "RobocraftName")]
|
||||
pub robocraft_name: String,
|
||||
/// ??? is confirmed?
|
||||
#[serde(rename = "Confirmed")]
|
||||
pub confirmed: bool,
|
||||
/// Freejam support code
|
||||
#[serde(rename = "SupportCode")]
|
||||
pub support_code: String,
|
||||
/// User's email address
|
||||
#[serde(rename = "EmailAddress")]
|
||||
pub email_address: String,
|
||||
/// Email address is verified?
|
||||
#[serde(rename = "EmailVerified")]
|
||||
pub email_verified: bool,
|
||||
/// Account creation date
|
||||
#[serde(rename = "CreatedDate")]
|
||||
pub created_date: String,
|
||||
/// Owned products (?)
|
||||
#[serde(rename = "Products")]
|
||||
pub products: Vec<String>,
|
||||
/// Account flags
|
||||
#[serde(rename = "Flags")]
|
||||
pub flags: Vec<String>,
|
||||
/// Account has a password?
|
||||
#[serde(rename = "HasPassword")]
|
||||
pub has_password: bool,
|
||||
/// Mailing lists that the account is signed up for
|
||||
#[serde(rename = "MailingLists")]
|
||||
pub mailing_lists: Vec<String>,
|
||||
/// Is Steam account? (always false)
|
||||
#[serde(rename = "HasSteam")]
|
||||
pub has_steam: bool,
|
||||
/// iss (?)
|
||||
#[serde(rename = "iss")]
|
||||
pub iss: String,
|
||||
/// sub (?)
|
||||
#[serde(rename = "sub")]
|
||||
pub sub: String,
|
||||
/// Token created at (unix time) (?)
|
||||
#[serde(rename = "iat")]
|
||||
pub iat: u64,
|
||||
/// Token expiry (unix time) (?)
|
||||
#[serde(rename = "exp")]
|
||||
pub exp: u64,
|
||||
}
|
||||
|
|
|
@ -34,11 +34,11 @@ fn robocraft_account() -> Result<(), ()> {
|
|||
}
|
||||
|
||||
// this requires human-interaction so it's disabled by default
|
||||
#[cfg(feature = "robocraft")]
|
||||
#[cfg(feature = "robocraft2")]
|
||||
#[allow(dead_code)]
|
||||
//#[test]
|
||||
fn robocraft2_account() -> Result<(), ()> {
|
||||
let token_maybe = robocraft2::PortalTokenProvider::portal();
|
||||
//#[tokio::test]
|
||||
async fn robocraft2_account() -> Result<(), ()> {
|
||||
let token_maybe = robocraft2::PortalTokenProvider::portal().await;
|
||||
assert!(token_maybe.is_ok());
|
||||
let token_provider = token_maybe.unwrap();
|
||||
let account_maybe = token_provider.get_account_info();
|
||||
|
@ -48,3 +48,19 @@ fn robocraft2_account() -> Result<(), ()> {
|
|||
assert_eq!(account.created_date, "2014-09-17T21:02:46");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// this requires human-interaction so it's disabled by default
|
||||
#[cfg(feature = "robocraft2")]
|
||||
#[allow(dead_code)]
|
||||
#[tokio::test]
|
||||
async fn robocraft2_simple_account() -> Result<(), ()> {
|
||||
let token_maybe = robocraft2::PortalTokenProvider::with_username("FJAPIC00L", "P4$$w0rd").await;
|
||||
assert!(token_maybe.is_ok());
|
||||
let token_provider = token_maybe.unwrap();
|
||||
let account_maybe = token_provider.get_account_info();
|
||||
assert!(account_maybe.is_ok());
|
||||
let account = account_maybe.unwrap();
|
||||
assert_eq!(account.display_name, "FJAPIC00L");
|
||||
assert_eq!(account.created_date, "2019-01-18T14:48:09");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -31,12 +31,12 @@ async fn robocraft_factory_default_query() -> Result<(), ()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "robocraft")]
|
||||
#[cfg(feature = "robocraft2")]
|
||||
#[tokio::test]
|
||||
async fn robocraft2_factory_default_query() -> Result<(), ()> {
|
||||
let api = robocraft2::FactoryAPI::with_auth(Box::new(robocraft2::PortalTokenProvider::portal().unwrap()));
|
||||
let api = robocraft2::FactoryAPI::with_auth(Box::new(robocraft2::PortalTokenProvider::with_username("FJAPIC00L", "P4$$w0rd").await.unwrap()));
|
||||
let result = api.list().await;
|
||||
//assert!(result.is_ok());
|
||||
assert!(result.is_ok());
|
||||
let robo_info = result.unwrap();
|
||||
assert_ne!(robo_info.results.len(), 0);
|
||||
for robot in &robo_info.results {
|
||||
|
|
Loading…
Add table
Reference in a new issue