diff --git a/Cargo.toml b/Cargo.toml index 5f1023b8..f9c38670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,3 +51,7 @@ harness = false [[test]] name = "authentication" harness = false + +[[test]] +name = "chart" +harness = false diff --git a/tests/chart.rs b/tests/chart.rs new file mode 100644 index 00000000..39a782e1 --- /dev/null +++ b/tests/chart.rs @@ -0,0 +1,250 @@ +#![cfg(test)] + +use cucumber::{given, then, when, Parameter, World}; +use futures::FutureExt; +use helpers::client::*; +use rand::{thread_rng, Rng}; +use ratings::{ + features::{ + common::entities::{calculate_band, VoteSummary}, + pb::chart::{Category, ChartData, Timeframe}, + }, + utils::{Config, Infrastructure}, +}; +use sqlx::Connection; +use strum::EnumString; + +mod helpers; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Parameter, EnumString)] +#[param(name = "category", regex = "Utilities|Development")] +pub enum TestCategory { + Utilities, + Development, +} + +impl From for Category { + fn from(value: TestCategory) -> Self { + match value { + TestCategory::Development => Self::Development, + TestCategory::Utilities => Self::Utilities, + } + } +} + +#[derive(Debug, World)] +#[world(init = Self::new)] +struct ChartWorld { + token: String, + snap_ids: Vec, + test_snap: String, + client: TestClient, + chart_data: Vec, +} + +impl ChartWorld { + async fn new() -> Self { + let config = Config::load().expect("could not load config"); + let client = TestClient::new(config.socket()); + + let token = client + .authenticate(&helpers::data_faker::rnd_sha_256()) + .await + .expect("could not authenticate test client") + .into_inner() + .token; + + Self { + snap_ids: Vec::with_capacity(25), + test_snap: Default::default(), + chart_data: Vec::new(), + client, + token, + } + } +} + +#[given(expr = "a snap with id {string} gets {int} votes where {int} are upvotes")] +async fn set_test_snap(world: &mut ChartWorld, snap_id: String, votes: usize, upvotes: usize) { + world.test_snap = snap_id; + + helpers::vote_generator::generate_votes( + &world.test_snap, + 1, + true, + upvotes as u64, + &world.client, + ) + .await + .expect("could not generate votes"); + + tracing::debug!("done generating upvotes"); + + helpers::vote_generator::generate_votes( + &world.test_snap, + 1, + false, + (votes - upvotes) as u64, + &world.client, + ) + .await + .expect("could not generate votes"); + + tracing::debug!("done generating downvotes"); +} + +#[given( + expr = "{int} test snaps gets between {int} and {int} votes, where {int} to {int} are upvotes" +)] +async fn generate_snaps( + world: &mut ChartWorld, + num_snaps: usize, + min_vote: usize, + max_vote: usize, + min_upvote: usize, + max_upvote: usize, +) { + let mut expected = Vec::with_capacity(num_snaps); + + for i in 1..=num_snaps { + tracing::debug!("starting snap {i} / {num_snaps}"); + + let (upvotes, votes) = { + let mut rng = thread_rng(); + + let upvotes = rng.gen_range(min_upvote..max_upvote); + let min_vote = Ord::max(upvotes, min_vote); + let votes = rng.gen_range(min_vote..=max_vote); + (upvotes, votes) + }; + + let id = helpers::data_faker::rnd_id(); + + helpers::vote_generator::generate_votes(&id, 1, true, upvotes as u64, &world.client) + .await + .expect("could not generate votes"); + + tracing::debug!("done generating upvotes ({i} / {num_snaps})"); + + helpers::vote_generator::generate_votes( + &id, + 1, + false, + (votes - upvotes) as u64, + &world.client, + ) + .await + .expect("could not generate votes"); + + tracing::debug!("done generating downvotes ({i} / {num_snaps})"); + + let summary = VoteSummary { + snap_id: id, + total_votes: votes as i64, + positive_votes: upvotes as i64, + }; + + expected.push((calculate_band(&summary).0.unwrap(), summary.snap_id)); + } + + expected.sort_unstable_by(|(band1, _), (band2, _)| band1.partial_cmp(band2).unwrap().reverse()); + world.snap_ids.extend(expected.drain(..).map(|(band, id)| { + tracing::debug!("id: {id}; band: {band}"); + id + })); +} + +#[when(expr = "the client fetches the top snaps")] +async fn get_chart(world: &mut ChartWorld) { + get_chart_internal(world, None).await; +} + +#[when(expr = "the client fetches the top snaps for {category}")] +async fn get_chart_of_category(world: &mut ChartWorld, category: TestCategory) { + get_chart_internal(world, Some(category.into())).await; +} + +async fn get_chart_internal(world: &mut ChartWorld, category: Option) { + world.chart_data = world + .client + .get_chart_of_category(Timeframe::Unspecified, category, &world.token) + .await + .expect("couldn't get chart") + .into_inner() + .ordered_chart_data; +} + +#[then(expr = "the top {int} snaps are returned in the proper order")] +async fn chart_order(world: &mut ChartWorld, top: usize) { + assert_eq!(world.chart_data.len(), top); + + assert!(world + .chart_data + .iter() + .zip(world.snap_ids.iter()) + .all(|(data, id)| { + let left = &data + .rating + .as_ref() + .expect("no rating in chart data?") + .snap_id; + + tracing::debug!("chart data: {data:?}, expected: {id}"); + + left == id + })) +} + +#[then(expr = "the top snap returned is the one with the ID {string}")] +async fn check_test_snap(world: &mut ChartWorld, snap_id: String) { + assert_eq!( + world.test_snap, snap_id, + "feature file and test snap definition got out of sync" + ); + + assert_eq!( + &world.chart_data[0].rating.as_ref().unwrap().snap_id, + &snap_id, + "top chart result is not test snap" + ); +} + +/// Automatically clears and snaps with >= TO_CLEAR votes, preventing them from interfering with tests +/// Being independent, while also not affecting other tests that require lower vote counts +async fn clear_db() { + const TO_CLEAR: usize = 3; + + let config = Config::load().unwrap(); + let infra = Infrastructure::new(&config).await.unwrap(); + let mut conn = infra.repository().await.unwrap(); + + let mut tx = conn.begin().await.unwrap(); + + sqlx::query( + r#"DELETE FROM votes WHERE snap_id IN + (SELECT snap_id FROM votes GROUP BY snap_id HAVING COUNT(*) >= $1) + "#, + ) + .bind(TO_CLEAR as i64) + .execute(&mut *tx) + .await + .unwrap(); + + sqlx::query("TRUNCATE TABLE snap_categories") + .execute(&mut *tx) + .await + .unwrap(); + + tx.commit().await.unwrap(); +} + +#[tokio::main] +async fn main() { + ChartWorld::cucumber() + .before(|_, _, _, _| clear_db().boxed_local()) + .repeat_failed() + .init_tracing() + .max_concurrent_scenarios(1) + .run_and_exit("tests/features/chart.feature") + .await +} diff --git a/tests/chart_tests/category.rs b/tests/chart_tests/category.rs deleted file mode 100644 index c273b3f1..00000000 --- a/tests/chart_tests/category.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! These tests require *specific* snaps because they do `snapd` lookups, so we can't -//! use the data-faked tests for this -//! -//! Warning! This actually causes problems if the number of votes is too big because the other -//! tests use the same DB and use *randomized* data, make sure you don't vote *too* much -//! on the test snap and break things. - -use futures::FutureExt; -use ratings::{ - app::AppContext, - features::pb::{ - chart::{Category, Timeframe}, - user::{AuthenticateResponse, VoteRequest}, - }, - utils::{Config, Infrastructure}, -}; - -use crate::{ - clear_test_snap, - helpers::{ - self, client_app::*, client_chart::*, client_user::*, test_data::TestData, - vote_generator::generate_votes, with_lifecycle::with_lifecycle, - }, - CLEAR_TEST_SNAP, -}; - -use super::super::{TESTING_SNAP_CATEGORIES, TESTING_SNAP_ID}; - -#[tokio::test] -async fn category_chart_filtering() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - CLEAR_TEST_SNAP.get_or_init(clear_test_snap).await; - - let data = TestData { - user_client: Some(UserClient::new(config.socket())), - app_ctx, - id: None, - token: None, - app_client: Some(AppClient::new(config.socket())), - snap_id: Some(TESTING_SNAP_ID.to_string()), - chart_client: Some(ChartClient::new(config.socket())), - categories: Some(TESTING_SNAP_CATEGORIES.iter().cloned().collect()), - }; - - with_lifecycle(async { - vote(data) - .then(multiple_votes) - .then(is_in_right_category) - .then(is_not_in_wrong_category) - .await; - }) - .await; - Ok(()) -} - -/// Run a regular vote so that category information is gotten -async fn vote(mut data: TestData) -> TestData { - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client.authenticate(&id).await.unwrap().into_inner(); - let token: String = response.token; - data.token = Some(token.to_string()); - - let token = data.token.clone().unwrap(); - let client = data.user_client.clone().unwrap(); - - let ballet = VoteRequest { - snap_id: data.snap_id.clone().unwrap(), - snap_revision: 2, - vote_up: true, - }; - - client - .vote(&token, ballet) - .await - .expect("vote should succeed") - .into_inner(); - data -} - -// Does an app voted against multiple times appear correctly in the chart? -pub async fn multiple_votes(data: TestData) -> TestData { - // This should rank our snap_id at the top of the chart, but only for our category - generate_votes( - &data.snap_id.clone().unwrap(), - 111, - true, - 50, - data.user_client.as_ref().unwrap(), - ) - .await - .expect("Votes should succeed"); - - data -} - -async fn is_in_right_category(data: TestData) -> TestData { - for category in data.categories.as_ref().unwrap() { - let chart_data_result = data - .chart_client - .as_ref() - .unwrap() - .get_chart_of_category( - Timeframe::Unspecified, - Some(*category), - &data.token.clone().unwrap(), - ) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - assert!(chart_data_result - .into_iter() - .filter_map(|v| v.rating.map(|v| v.snap_id)) - .any(|v| &v == data.snap_id.as_ref().unwrap())); - } - - data -} - -async fn is_not_in_wrong_category(data: TestData) -> TestData { - debug_assert!(!data - .categories - .as_ref() - .unwrap() - .contains(&Category::ArtAndDesign)); - - let chart_data_result = data - .chart_client - .as_ref() - .unwrap() - .get_chart_of_category( - Timeframe::Unspecified, - Some(Category::ArtAndDesign), - &data.token.clone().unwrap(), - ) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - assert!(chart_data_result - .into_iter() - .filter_map(|v| v.rating.map(|v| v.snap_id)) - .all(|v| &v != data.snap_id.as_ref().unwrap())); - - data -} diff --git a/tests/chart_tests/lifecycle_test.rs b/tests/chart_tests/lifecycle_test.rs deleted file mode 100644 index 2a021a50..00000000 --- a/tests/chart_tests/lifecycle_test.rs +++ /dev/null @@ -1,239 +0,0 @@ -use futures::FutureExt; -use ratings::{ - app::AppContext, - features::pb::{ - chart::Timeframe, - common::{Rating, RatingsBand}, - user::AuthenticateResponse, - }, - utils::{Config, Infrastructure}, -}; - -use super::super::helpers::with_lifecycle::with_lifecycle; -use crate::helpers::vote_generator::generate_votes; -use crate::helpers::{self, client_app::*}; -use crate::helpers::{client_chart::*, test_data::TestData}; -use crate::helpers::{client_user::*, data_faker}; - -#[tokio::test] -async fn chart_lifecycle_test() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - let data = TestData { - user_client: Some(UserClient::new(config.socket())), - app_ctx, - id: None, - token: None, - app_client: Some(AppClient::new(config.socket())), - snap_id: Some(data_faker::rnd_id()), - chart_client: Some(ChartClient::new(config.socket())), - categories: None, - }; - - with_lifecycle(async { - vote_once(data.clone()) - .then(multiple_votes) - .then(timeframed_votes_dont_appear) - .await; - }) - .await; - Ok(()) -} - -// Does an app voted against once appear correctly in the chart? -async fn vote_once(mut data: TestData) -> TestData { - let vote_up = true; - - // Fill up chart with other votes so ours doesn't appear - for _ in 0..20 { - generate_votes( - &data_faker::rnd_id(), - 111, - vote_up, - 25, - data.user_client.as_ref().unwrap(), - ) - .await - .expect("Votes should succeed"); - } - - let vote_up = true; - - generate_votes( - &data.snap_id.clone().unwrap(), - 111, - vote_up, - 1, - data.user_client.as_ref().unwrap(), - ) - .await - .expect("Votes should succeed"); - - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client.authenticate(&id).await.unwrap().into_inner(); - let token: String = response.token; - data.token = Some(token.to_string()); - - let timeframe = Timeframe::Unspecified; - - let chart_data_result = data - .clone() - .chart_client - .unwrap() - .get_chart(timeframe, &data.token.clone().unwrap()) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - let result = chart_data_result.into_iter().find(|chart_data| { - if let Some(rating) = &chart_data.rating { - rating.snap_id == data.snap_id.clone().unwrap() - } else { - false - } - }); - - // Should not appear in chart - assert_eq!(result, None); - - data -} - -// Does an app voted against multiple times appear correctly in the chart? -async fn multiple_votes(mut data: TestData) -> TestData { - let vote_up = true; - let expected_raw_rating = 0.8; - let expected_rating = Rating { - snap_id: data.snap_id.clone().unwrap(), - total_votes: 101, - ratings_band: RatingsBand::VeryGood.into(), - }; - - // This should rank our snap_id at the top of the chart - generate_votes( - &data.snap_id.clone().unwrap(), - 111, - vote_up, - 100, - data.user_client.as_ref().unwrap(), - ) - .await - .expect("Votes should succeed"); - - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client.authenticate(&id).await.unwrap().into_inner(); - let token: String = response.token; - data.token = Some(token.to_string()); - - let timeframe = Timeframe::Unspecified; - - let chart_data_result = data - .clone() - .chart_client - .unwrap() - .get_chart(timeframe, &data.token.clone().unwrap()) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - // Should be at the top of the chart - if let Some(chart_data) = chart_data_result.first() { - let actual_rating = chart_data.rating.clone().expect("Rating should exist"); - let actual_raw_rating = chart_data.raw_rating; - - assert_eq!(expected_rating, actual_rating); - assert!(expected_raw_rating < actual_raw_rating); - } else { - panic!("No chart data available"); - } - - data -} - -// Does the Timeframe correctly filter out app data? -async fn timeframed_votes_dont_appear(mut data: TestData) -> TestData { - let mut conn = data.repository().await.unwrap(); - - // Timewarp the votes back two months so they are out of the requested timeframe - sqlx::query("UPDATE votes SET created = created - INTERVAL '2 months' WHERE snap_id = $1") - .bind(&data.snap_id.clone().unwrap()) - .execute(&mut *conn) - .await - .unwrap(); - - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client.authenticate(&id).await.unwrap().into_inner(); - let token: String = response.token; - data.token = Some(token.to_string()); - - let timeframe = Timeframe::Month; - - let chart_data_result = data - .clone() - .chart_client - .unwrap() - .get_chart(timeframe, &data.token.clone().unwrap()) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - let result = chart_data_result.into_iter().find(|chart_data| { - if let Some(rating) = &chart_data.rating { - rating.snap_id == data.snap_id.clone().unwrap() - } else { - false - } - }); - - // Should no longer find the ratings as they are too old - assert_eq!(result, None); - - let expected_raw_rating = 0.8; - let expected_rating = Rating { - snap_id: data.snap_id.clone().unwrap(), - total_votes: 101, - ratings_band: RatingsBand::VeryGood.into(), - }; - - // Unspecified timeframe should now pick up the ratings again - let timeframe = Timeframe::Unspecified; - let chart_data_result = data - .clone() - .chart_client - .unwrap() - .get_chart(timeframe, &data.token.clone().unwrap()) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - let result = chart_data_result.into_iter().find(|chart_data| { - if let Some(rating) = &chart_data.rating { - rating.snap_id == data.snap_id.clone().unwrap() - } else { - false - } - }); - - let actual_rating = result.clone().unwrap().rating.unwrap(); - let actual_raw_rating = result.unwrap().raw_rating; - - assert_eq!(expected_rating, actual_rating); - assert!(expected_raw_rating < actual_raw_rating); - - data -} diff --git a/tests/features/chart.feature b/tests/features/chart.feature new file mode 100644 index 00000000..3f0daaf4 --- /dev/null +++ b/tests/features/chart.feature @@ -0,0 +1,17 @@ +Feature: List of top 20 snaps + Background: + Given a snap with id "3Iwi803Tk3KQwyD6jFiAJdlq8MLgBIoD" gets 100 votes where 75 are upvotes + Given 25 test snaps gets between 150 and 200 votes, where 125 to 175 are upvotes + + Scenario: Tails opens the store homepage, seeing the top snaps + When the client fetches the top snaps + Then the top 20 snaps are returned in the proper order + + Scenario Outline: Tails opens a few store categories, retrieving the top chart for those snaps + When the client fetches the top snaps for + Then the top snap returned is the one with the ID "3Iwi803Tk3KQwyD6jFiAJdlq8MLgBIoD" + + Examples: + | category | + | Utilities | + | Development | \ No newline at end of file diff --git a/tests/helpers/vote_generator.rs b/tests/helpers/vote_generator.rs index 62c5df05..4d8214d7 100644 --- a/tests/helpers/vote_generator.rs +++ b/tests/helpers/vote_generator.rs @@ -1,5 +1,8 @@ +use std::sync::Arc; + use super::client_user::*; use crate::helpers; +use futures::future::join_all; use ratings::features::pb::user::{AuthenticateResponse, VoteRequest}; pub async fn generate_votes( @@ -9,9 +12,26 @@ pub async fn generate_votes( count: u64, client: &UserClient, ) -> Result<(), Box> { + let mut joins = Vec::with_capacity(count as usize); + + let snap_id = Arc::new(snap_id.to_string()); + let client = Arc::new(client.clone()); for _ in 0..count { - register_and_vote(snap_id, snap_revision, vote_up, client).await?; + let snap_id = snap_id.clone(); + let client = client.clone(); + joins.push(tokio::spawn(async move { + register_and_vote(&snap_id, snap_revision, vote_up, &client).await + })); + } + + for join in join_all(joins).await { + // The second ? returns an error about type sizing for some reason + #[allow(clippy::question_mark)] + if let Err(err) = join? { + return Err(err); + } } + Ok(()) } @@ -20,7 +40,7 @@ async fn register_and_vote( snap_revision: i32, vote_up: bool, client: &UserClient, -) -> Result<(), Box> { +) -> Result<(), Box> { let id: String = helpers::data_faker::rnd_sha_256(); let response: AuthenticateResponse = client .authenticate(&id) diff --git a/tests/mod.rs b/tests/mod.rs deleted file mode 100644 index 8a6c8f28..00000000 --- a/tests/mod.rs +++ /dev/null @@ -1,55 +0,0 @@ -use ratings::{ - app::AppContext, - features::pb::chart::Category, - utils::{Config, Infrastructure}, -}; -use sqlx::Connection; -use tokio::sync::OnceCell; - -const TESTING_SNAP_ID: &str = "3Iwi803Tk3KQwyD6jFiAJdlq8MLgBIoD"; -const TESTING_SNAP_CATEGORIES: [Category; 2] = [Category::Utilities, Category::Development]; - -/// Call [`clear_test_snap`] with this at the start of category tests to clear the Test snap info, -/// this prevents it from polluting other integration tests by repeated runs eventually outstripping -/// the random data. -static CLEAR_TEST_SNAP: OnceCell<()> = OnceCell::const_new(); - -async fn clear_test_snap() { - let config = Config::load().unwrap(); - let infra = Infrastructure::new(&config).await.unwrap(); - let app_ctx = AppContext::new(&config, infra); - let mut conn = app_ctx.infrastructure().repository().await.unwrap(); - - let mut tx = conn.begin().await.unwrap(); - - sqlx::query("DELETE FROM votes WHERE snap_id = $1;") - .bind(TESTING_SNAP_ID) - .execute(&mut *tx) - .await - .unwrap(); - - sqlx::query("DELETE FROM snap_categories WHERE snap_id = $1;") - .bind(TESTING_SNAP_ID) - .execute(&mut *tx) - .await - .unwrap(); - - tx.commit().await.unwrap(); -} - -mod user_tests { - mod category; - mod reject_invalid_register_test; - mod simple_lifecycle_test; -} - -mod app_tests { - mod lifecycle_test; -} - -mod chart_tests { - mod category; - mod lifecycle_test; -} - -mod helpers;