Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: Include quest completion timestamp in get_quest_participants response #359

Merged
merged 12 commits into from
Jan 29, 2025
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ regex = "1.10.0"
ctor = "0.2.6"
axum-client-ip = "0.4.0"
jsonwebtoken = "9"
tower = "0.4.13"
tower = "0.4.13"
4 changes: 3 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,10 @@ pub fn load() -> Config {
let args: Vec<String> = env::args().collect();
let config_path = if args.len() <= 1 {
"config.toml"
} else {
} else if (args.len() > 1) && (args.get(1).unwrap().contains("toml")) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why ?

args.get(1).unwrap()
} else {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to have these changes, you can revert

"config.toml"
};
let file_contents = fs::read_to_string(config_path);
if file_contents.is_err() {
Expand Down
8 changes: 3 additions & 5 deletions src/endpoints/admin/quest/get_quest_users.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::middleware::auth::auth_middleware;
use crate::utils::to_hex;
use crate::utils::verify_quest_auth;
use crate::utils::to_hex;
use crate::{
models::{AppState, CompletedTaskDocument, QuestDocument, QuestTaskDocument},
utils::get_error,
Expand Down Expand Up @@ -47,10 +47,8 @@ pub async fn get_quest_users_handler(
Err(e) => return get_error(format!("Error fetching tasks: {}", e)),
};

let task_ids_result: Result<Vec<u32>, _> = task_cursor
.map_ok(|doc| doc.id as u32)
.try_collect()
.await;
let task_ids_result: Result<Vec<u32>, _> =
task_cursor.map_ok(|doc| doc.id as u32).try_collect().await;

let task_ids = match task_ids_result {
Ok(ids) => ids,
Expand Down
10 changes: 4 additions & 6 deletions src/endpoints/admin/quest_boost/get_boost_winners.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ use crate::middleware::auth::auth_middleware;
use crate::models::{AppState, BoostTable};
use crate::utils::get_error;
use axum::{
extract::{Query, State, Extension},
extract::{Extension, Query, State},
response::IntoResponse,
Json,
};
use axum_auto_routes::route;
use mongodb::bson::doc;
use serde::Deserialize;
use std::sync::Arc;
use serde_json::json;
use std::sync::Arc;

#[derive(Deserialize)]
pub struct GetBoostWinnersParams {
Expand All @@ -28,10 +28,8 @@ pub async fn get_boost_winners_handler(
let filter = doc! { "id": params.boost_id };

match collection.find_one(filter, None).await {
Ok(Some(boost_doc)) => {
Json(json!({ "winners": boost_doc.winner })).into_response()
},
Ok(Some(boost_doc)) => Json(json!({ "winners": boost_doc.winner })).into_response(),
Ok(None) => get_error(format!("Boost with id {} not found", params.boost_id)),
Err(e) => get_error(format!("Error fetching boost winners: {}", e)),
}
}
}
219 changes: 200 additions & 19 deletions src/endpoints/get_quest_participants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,40 +52,59 @@ pub async fn handler(
}
}
},
// First group by address to get the max timestamp for each participant
doc! {
"$group": {
"_id": "$address",
"count" : { "$sum": 1 }
"count": { "$sum": 1 },
"last_completion": { "$max": "$timestamp" }
}
},
// Add a field to indicate if all tasks are completed
doc! {
"$match": {
"count": tasks_count as i64
"$addFields": {
"completed_all": {
"$eq": ["$count", tasks_count as i64]
}
}
},
// Conditionally set quest_completion_time based on completed_all
doc! {
"$facet": {
"count": [
{
"$count": "count"
"$addFields": {
"quest_completion_time": {
"$cond": {
"if": "$completed_all",
"then": "$last_completion",
"else": null
}
}
}
},
// Sort by completion time (null values will be at the end)
doc! {
"$sort": {
"quest_completion_time": 1
}
},
doc! {
"$facet": {
"total": [
{ "$count": "count" }
],
"firstParticipants": [
{
"$limit": 3
}
"participants": [
{ "$project": {
"address": "$_id",
"tasks_completed": "$count",
"quest_completion_time": 1,
"_id": 0
}}
]
}
},
doc! {
"$project": {
"count": {
"$arrayElemAt": [
"$count.count",
0
]
},
"firstParticipants": "$firstParticipants._id"
"total": { "$ifNull": [{ "$arrayElemAt": ["$total.count", 0] }, 0] },
"first_participants": "$participants"
}
},
];
Expand All @@ -106,5 +125,167 @@ pub async fn handler(
}
}

return (StatusCode::OK, Json(res)).into_response();
(StatusCode::OK, Json(res)).into_response()
}
#[cfg(test)]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to have the tests, but it was a good idea to make sure everything was working. The problem is that if we run tests in production, it'll reset the db

mod tests {
use crate::{
config::{self, Config},
logger,
};

use super::*;
use axum::body::HttpBody;
use axum::{body::Bytes, http::StatusCode};
use mongodb::{bson::doc, Client, Database};
use reqwest::Url;
use serde_json::Value;
use starknet::providers::{jsonrpc::HttpTransport, JsonRpcClient};
use std::sync::Arc;
use tokio::sync::Mutex;

async fn setup_test_db() -> Database {
let client = Client::with_uri_str("mongodb://localhost:27017")
.await
.expect("Failed to create MongoDB client");
let db = client.database("test_db");

// Clear collections before each test
db.collection::<Document>("tasks").drop(None).await.ok();
db.collection::<Document>("completed_tasks")
.drop(None)
.await
.ok();

db
}

async fn insert_test_data(db: Database, quest_id: i64, num_tasks: i64, num_participants: i64) {
let tasks_collection = db.collection::<Document>("tasks");
let completed_tasks_collection = db.collection::<Document>("completed_tasks");

// Insert tasks
for task_id in 1..=num_tasks {
tasks_collection
.insert_one(
doc! {
"id": task_id,
"quest_id": quest_id,
},
None,
)
.await
.unwrap();
}

// Insert completed tasks for participants
// Each participant will have a different timestamp for each task
// timestamp will be 1000 - (participant * 10) + task_id
// This way, the last task for each participant will have the highest timestamp
// and the last participant will be the one who completed the quest first

// 2..=num_participants: skip the first participant
// The first participant haven't completed all the tasks
for participant in 1..=num_participants {
let address = format!("participant_{}", participant);
let base_timestamp = 1000 - (participant * 10);

// First participant only do one task => not completed the quest yet
if participant == 1 {
completed_tasks_collection.insert_one(
doc! {
"address": address.clone(),
"task_id": 1,
"timestamp": base_timestamp + 1
},
None,
).await.unwrap();
} else {
for task_id in 1..=num_tasks {
completed_tasks_collection
.insert_one(
doc! {
"address": address.clone(),
"task_id": task_id,
// Last task for each participant will have the highest timestamp
"timestamp": base_timestamp + task_id
},
None,
)
.await
.unwrap();
}
}
}
}

#[tokio::test]
async fn test_get_quest_participants() {
// Setup
let db = setup_test_db().await;
let conf = config::load();
let logger = logger::Logger::new(&conf.watchtower);
let provider = JsonRpcClient::new(HttpTransport::new(
Url::parse(&conf.variables.rpc_url).unwrap(),
));

let app_state = Arc::new(AppState {
db: db.clone(),
last_task_id: Mutex::new(0),
last_question_id: Mutex::new(0),
conf,
logger,
provider,
});

// Test data
let quest_id = 1;
let num_tasks = 3;
let num_participants = 5;

insert_test_data(db.clone(), quest_id, num_tasks, num_participants).await;

// Create request
let query = GetQuestParticipantsQuery {
quest_id: quest_id as u32,
};

// Execute request
let response = handler(State(app_state), Query(query))
.await
.into_response();

// Verify response
assert_eq!(response.status(), StatusCode::OK);

// Get the response body as bytes
let body_bytes = match response.into_body().data().await {
Some(Ok(bytes)) => bytes,
_ => panic!("Failed to get response body"),
};

// Parse the body
let body: Value = serde_json::from_slice(&body_bytes).unwrap();

// We has excluded the first participant from the test data
assert_eq!(body["total"], num_participants);

// Verify first participants
let first_participants = body["first_participants"].as_array().unwrap();

// Verify quest completion timestamp
let quest_completion_timestamp = body["first_participants"][1]["quest_completion_time"]
.as_i64()
.unwrap();
assert_eq!(quest_completion_timestamp, 953);

// Verify participant not completed the quest
let participant_not_completed = first_participants.iter().find(|participant| {
participant["address"].as_str().unwrap() == "participant_1"
}).unwrap();

assert_eq!(participant_not_completed["tasks_completed"].as_i64().unwrap(), 1);
assert_eq!(participant_not_completed["quest_completion_time"].as_i64(), None); // Not completed

}
}
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ use mongodb::{bson::doc, options::ClientOptions, Client};
use reqwest::Url;
use starknet::providers::{jsonrpc::HttpTransport, JsonRpcClient};
use std::net::SocketAddr;
use std::sync::Mutex;
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync;
use tower_http::cors::{Any, CorsLayer};
use utils::WithState;
Expand Down
4 changes: 4 additions & 0 deletions src/tests/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ pub mod tests {
let response = client.get(endpoint).send().await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}

// #[tokio::test]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to have these comments

// pub async fn test_get_quest_participants() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can remove this

// let endpoint = format!("http://
}
2 changes: 1 addition & 1 deletion src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
mod utils;
mod endpoints;
mod utils;