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

Migrate tests to cucumber #78

Merged
merged 7 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
620 changes: 578 additions & 42 deletions Cargo.lock

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,18 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }

[build-dependencies]
tonic-build = { version = "0.11", features = ["prost"] }

[dev-dependencies]
cucumber = { version = "0.20.2", features = ["libtest", "tracing"] }

[[test]]
name = "voting"
harness = false

[[test]]
name = "authentication"
harness = false

[[test]]
name = "chart"
harness = false
46 changes: 44 additions & 2 deletions src/features/common/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ const INSUFFICIENT_VOTES_QUANTITY: i64 = 25;

/// A descriptive mapping of a number of ratings to a general indicator of "how good"
/// an app can be said to be.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(missing_docs)]
pub enum RatingsBand {
VeryGood = 0,
Good = 1,
Neutral = 2,
Poor = 3,
VeryPoor = 4,
#[default]
InsufficientVotes = 5,
}

Expand Down Expand Up @@ -45,10 +47,38 @@ impl RatingsBand {
}
}

impl PartialOrd for RatingsBand {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if matches!(self, RatingsBand::InsufficientVotes)
|| matches!(other, RatingsBand::InsufficientVotes)
{
None
} else {
// The ratings bad values are actually backwards from an ordering perspective,
// so flip them
ZoopOTheGoop marked this conversation as resolved.
Show resolved Hide resolved
let max = Self::InsufficientVotes as u8;
(max - (*self as u8)).partial_cmp(&(max - (*other as u8)))
}
}
}

impl From<crate::features::pb::common::RatingsBand> for RatingsBand {
fn from(value: crate::features::pb::common::RatingsBand) -> Self {
match value {
pb::RatingsBand::VeryGood => Self::VeryGood,
pb::RatingsBand::Good => Self::Good,
pb::RatingsBand::Neutral => Self::Neutral,
pb::RatingsBand::Poor => Self::Poor,
pb::RatingsBand::VeryPoor => Self::VeryPoor,
pb::RatingsBand::InsufficientVotes => Self::InsufficientVotes,
}
}
}

/// A descriptive rating object, usually used converted and transferred over the wire.
/// This is an aggregated rating for a snap without holding every raw value, as determined
/// by [`RatingsBand`].
#[derive(Debug, Clone, FromRow)]
#[derive(Debug, Clone, FromRow, Default)]
pub struct Rating {
/// The ID of the snap this rating is for
pub snap_id: String,
Expand Down Expand Up @@ -81,6 +111,18 @@ impl Rating {
}
}

impl From<crate::features::pb::common::Rating> for Rating {
fn from(value: crate::features::pb::common::Rating) -> Self {
Self {
snap_id: value.snap_id,
total_votes: value.total_votes,
ratings_band: crate::features::pb::common::RatingsBand::try_from(value.ratings_band)
.unwrap()
.into(),
}
}
}

/// A summary of votes for a given snap, this is then aggregated before transfer.
#[derive(Debug, Clone, FromRow)]
pub struct VoteSummary {
Expand Down
3 changes: 1 addition & 2 deletions src/features/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
//! Contains various feature implementations for the ratings backend.

pub mod chart;
pub mod common;
pub mod pb;
pub mod rating;
pub mod user;

mod common;
17 changes: 17 additions & 0 deletions test.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
APP_ENV=dev
APP_HOST=0.0.0.0
APP_JWT_SECRET=deadbeef
APP_LOG_LEVEL=info
APP_NAME=ratings
APP_PORT=8080
# Update this with some real PostgreSQL details
APP_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings
APP_MIGRATION_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings

DOCKER_POSTGRES_USER=postgres
DOCKER_POSTGRES_PASSWORD=@1234
DOCKER_MIGRATION_USER=migration_user
DOCKER_MIGRATION_PASSWORD=strongpassword
DOCKER_SERVICE_USER=service
DOCKER_SERVICE_PASSWORD=covfefe!1
DOCKER_RATINGS_DB=ratings
118 changes: 0 additions & 118 deletions tests/app_tests/lifecycle_test.rs

This file was deleted.

104 changes: 104 additions & 0 deletions tests/authentication.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use cucumber::{given, then, when, World};

use helpers::client::*;
use ratings::utils::{Config, Infrastructure};
use sqlx::Row;
use tonic::{Code, Status};

mod helpers;

#[derive(Clone, Debug, Default, World)]
struct AuthenticationWorld {
client_hash: String,
client: Option<TestClient>,
tokens: Vec<String>,
auth_error: Option<Status>,
}

#[given(expr = "a valid client hash")]
fn generate_hash(world: &mut AuthenticationWorld) {
world.client_hash = helpers::data_faker::rnd_sha_256();
}

#[given(expr = "a bad client with the hash {word}")]
fn with_hash(world: &mut AuthenticationWorld, hash: String) {
world.client_hash = hash;
}

#[when(expr = "the client attempts to authenticate")]
#[when(expr = "that client authenticates a second time")]
#[given(expr = "an authenticated client")]
async fn authenticate(world: &mut AuthenticationWorld) {
let config = Config::load().expect("Could not load config");

world.client = Some(TestClient::new(config.socket()));

match world
.client
.as_ref()
.unwrap()
.authenticate(&world.client_hash)
.await
{
Ok(resp) => world.tokens.push(resp.into_inner().token),
Err(err) => world.auth_error = Some(err),
}
}

#[then(expr = "the authentication is rejected")]
fn check_rejected(world: &mut AuthenticationWorld) {
assert!(world.auth_error.is_some());

let err = world.auth_error.as_ref().unwrap();

assert_eq!(err.code(), Code::InvalidArgument);
}

#[then(expr = "the returned token is valid")]
#[then(expr = "both tokens are valid")]
fn verify_token(world: &mut AuthenticationWorld) {
assert!(
world.auth_error.is_none(),
"needed clean exit, instead got status {:?}",
world.auth_error
);

let config = Config::load().expect("Could not load config");

for token in world.tokens.iter() {
helpers::assert::assert_token_is_valid(token, &config.jwt_secret);
}
}

#[then(expr = "the hash is only in the database once")]
async fn no_double_auth(world: &mut AuthenticationWorld) {
// In other test scenarios we might do this when we init the world, but
// given authentication only needs this once this is fine
let config = Config::load().expect("Could not load config");
let infra = Infrastructure::new(&config)
.await
.expect("Could not init DB");

// User still registered
let row = sqlx::query("SELECT COUNT(*) FROM users WHERE client_hash = $1")
.bind(&world.client_hash)
.fetch_one(&mut *infra.repository().await.expect("could not connect to DB"))
.await
.unwrap();

let count: i64 = row.try_get("count").expect("Failed to get count");

// Only appears in db once
assert_eq!(count, 1);
}

#[tokio::main]
async fn main() {
dotenv::from_filename("test.env").ok();

AuthenticationWorld::cucumber()
.repeat_skipped()
.init_tracing()
.run_and_exit("tests/features/user/authentication.feature")
.await
}
Loading
Loading