From c58b7cdfff20bdac6cce66d2e9e5df61a9de4655 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:59:13 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20CodeRabbit=20Chat:=20Implement?= =?UTF-8?q?=20requested=20code=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user_interests_revision_conflicts_bdd.rs | 233 +++++++++++ .../db_support.rs | 124 ++++++ .../flow_support.rs | 373 ++++++++++++++++++ 3 files changed, 730 insertions(+) create mode 100644 backend/tests/user_interests_revision_conflicts_bdd.rs create mode 100644 backend/tests/user_interests_revision_conflicts_bdd/db_support.rs create mode 100644 backend/tests/user_interests_revision_conflicts_bdd/flow_support.rs diff --git a/backend/tests/user_interests_revision_conflicts_bdd.rs b/backend/tests/user_interests_revision_conflicts_bdd.rs new file mode 100644 index 00000000..75dbb4c6 --- /dev/null +++ b/backend/tests/user_interests_revision_conflicts_bdd.rs @@ -0,0 +1,233 @@ +//! Behavioural coverage for revision-safe interests updates against real DB wiring. + +use rstest::fixture; +use rstest_bdd_macros::{given, scenario, then, when}; +use uuid::Uuid; + +mod support; + +use support::handle_cluster_setup_failure; + +#[path = "../src/server/config.rs"] +#[expect( + dead_code, + reason = "tests import ServerConfig from server_config for DB-backed HTTP flows" +)] +mod server_config; +pub(crate) use server_config::ServerConfig; + +#[path = "../src/server/state_builders.rs"] +mod state_builders; + +#[path = "user_interests_revision_conflicts_bdd/flow_support.rs"] +mod flow_support; + +use flow_support::{ + FIRST_THEME_ID, PreferencesData, SAFETY_TOGGLE_ID, SECOND_THEME_ID, THIRD_THEME_ID, World, + assert_conflict_snapshot, assert_interests_snapshot, assert_preferences_snapshot, is_skipped, + run_first_write, run_matching_revision_write, run_missing_revision_conflict, + run_preserve_non_interest_flow, run_stale_revision_conflict, seed_preferences, seed_user, + setup_db_context, +}; +use support::profile_interests::FIXTURE_AUTH_ID; + +#[fixture] +fn world() -> World { + World::default() +} + +#[given("db-present startup mode backed by embedded postgres")] +fn db_present_startup_mode_backed_by_embedded_postgres(world: &mut World) { + match setup_db_context() { + Ok(db) => { + seed_user( + db.database_url.as_str(), + Uuid::parse_str(FIXTURE_AUTH_ID).expect("valid fixture UUID"), + "Revision Ada", + ) + .expect("seed db user"); + world.db = Some(db); + world.skip_reason = None; + } + Err(error) => { + let _ = handle_cluster_setup_failure::<()>(error.as_str()); + world.skip_reason = Some(error); + } + } +} + +#[given("existing preferences revision 1 with preserved safety and unit settings")] +fn existing_preferences_revision_1_with_preserved_safety_and_unit_settings(world: &mut World) { + if is_skipped(world) { + return; + } + + let db = world.db.as_ref().expect("db context"); + seed_preferences( + db.database_url.as_str(), + Uuid::parse_str(FIXTURE_AUTH_ID).expect("valid fixture UUID"), + PreferencesData::new(&[FIRST_THEME_ID], &[SAFETY_TOGGLE_ID], "imperial", 1), + ) + .expect("seed user preferences"); +} + +#[given("existing preferences revision 2")] +fn existing_preferences_revision_2(world: &mut World) { + if is_skipped(world) { + return; + } + + let db = world.db.as_ref().expect("db context"); + seed_preferences( + db.database_url.as_str(), + Uuid::parse_str(FIXTURE_AUTH_ID).expect("valid fixture UUID"), + PreferencesData::new(&[FIRST_THEME_ID], &[SAFETY_TOGGLE_ID], "metric", 2), + ) + .expect("seed user preferences"); +} + +#[when("the client writes interests for the first time")] +fn the_client_writes_interests_for_the_first_time(world: &mut World) { + run_first_write(world); +} + +#[when("the client writes interests twice using the returned revision")] +fn the_client_writes_interests_twice_using_the_returned_revision(world: &mut World) { + run_matching_revision_write(world); +} + +#[when("the client writes interests with stale expected revision 1")] +fn the_client_writes_interests_with_stale_expected_revision_1(world: &mut World) { + run_stale_revision_conflict(world); +} + +#[when("the client writes interests without expected revision after preferences exist")] +fn the_client_writes_interests_without_expected_revision_after_preferences_exist( + world: &mut World, +) { + run_missing_revision_conflict(world); +} + +#[when("the client updates interests and then fetches preferences")] +fn the_client_updates_interests_and_then_fetches_preferences(world: &mut World) { + run_preserve_non_interest_flow(world); +} + +#[then("the first interests response includes revision 1")] +fn the_first_interests_response_includes_revision_1(world: &mut World) { + if is_skipped(world) { + return; + } + + assert_interests_snapshot( + world.first_update.as_ref().expect("first update response"), + &[FIRST_THEME_ID], + 1, + ); +} + +#[then("the second interests response includes revision 2")] +fn the_second_interests_response_includes_revision_2(world: &mut World) { + if is_skipped(world) { + return; + } + + assert_interests_snapshot( + world + .second_update + .as_ref() + .expect("second update response"), + &[SECOND_THEME_ID], + 2, + ); +} + +#[then("the response is a conflict with expected revision 1 and actual revision 2")] +fn the_response_is_a_conflict_with_expected_revision_1_and_actual_revision_2(world: &mut World) { + if is_skipped(world) { + return; + } + + assert_conflict_snapshot( + world.first_update.as_ref().expect("conflict response"), + Some(1), + 2, + ); +} + +#[then("the response is a conflict with missing expected revision and actual revision 1")] +fn the_response_is_a_conflict_with_missing_expected_revision_and_actual_revision_1( + world: &mut World, +) { + if is_skipped(world) { + return; + } + + assert_conflict_snapshot( + world.first_update.as_ref().expect("conflict response"), + None, + 1, + ); +} + +#[then("the fetched preferences preserve safety and unit settings while advancing revision 2")] +fn the_fetched_preferences_preserve_safety_and_unit_settings_while_advancing_revision_2( + world: &mut World, +) { + if is_skipped(world) { + return; + } + + assert_interests_snapshot( + world.first_update.as_ref().expect("update response"), + &[THIRD_THEME_ID], + 2, + ); + assert_preferences_snapshot( + world.preferences.as_ref().expect("preferences response"), + &[THIRD_THEME_ID], + &[SAFETY_TOGGLE_ID], + "imperial", + 2, + ); +} + +#[scenario( + path = "tests/features/user_interests_revision_conflicts.feature", + name = "First interests write creates revision 1" +)] +fn first_interests_write_creates_revision_1(world: World) { + drop(world); +} + +#[scenario( + path = "tests/features/user_interests_revision_conflicts.feature", + name = "Matching expected revision advances interests revision" +)] +fn matching_expected_revision_advances_interests_revision(world: World) { + drop(world); +} + +#[scenario( + path = "tests/features/user_interests_revision_conflicts.feature", + name = "Stale expected revision returns a conflict" +)] +fn stale_expected_revision_returns_a_conflict(world: World) { + drop(world); +} + +#[scenario( + path = "tests/features/user_interests_revision_conflicts.feature", + name = "Missing expected revision after preferences exist returns a conflict" +)] +fn missing_expected_revision_after_preferences_exist_returns_a_conflict(world: World) { + drop(world); +} + +#[scenario( + path = "tests/features/user_interests_revision_conflicts.feature", + name = "Interests updates preserve non-interest preferences fields" +)] +fn interests_updates_preserve_non_interest_preferences_fields(world: World) { + drop(world); +} \ No newline at end of file diff --git a/backend/tests/user_interests_revision_conflicts_bdd/db_support.rs b/backend/tests/user_interests_revision_conflicts_bdd/db_support.rs new file mode 100644 index 00000000..aa70ef05 --- /dev/null +++ b/backend/tests/user_interests_revision_conflicts_bdd/db_support.rs @@ -0,0 +1,124 @@ +//! DB bootstrap helpers for revision-safe interests BDD coverage. + +use pg_embedded_setup_unpriv::TemporaryDatabase; +use postgres::{Client, NoTls}; +use uuid::Uuid; + +use backend::outbound::persistence::{DbPool, PoolConfig}; + +use super::super::support::atexit_cleanup::shared_cluster_handle; +use super::super::support::{format_postgres_error, provision_template_database}; + +pub(crate) struct DbContext { + pub(crate) database_url: String, + pub(crate) pool: DbPool, + _database: TemporaryDatabase, +} + +#[derive(Default)] +pub(crate) struct World { + pub(crate) db: Option, + pub(crate) first_update: Option, + pub(crate) second_update: Option, + pub(crate) preferences: Option, + pub(crate) skip_reason: Option, +} + +pub(crate) fn is_skipped(world: &World) -> bool { + if let Some(reason) = world.skip_reason.as_deref() { + eprintln!("SKIP-TEST-CLUSTER: scenario skipped ({reason})"); + true + } else { + false + } +} + +pub(crate) fn setup_db_context() -> Result { + let cluster = shared_cluster_handle().map_err(|error| error.to_string())?; + let database = provision_template_database(cluster).map_err(|error| error.to_string())?; + let database_url = database.url().to_owned(); + let pool = super::run_async(DbPool::new( + PoolConfig::new(database_url.as_str()) + .with_max_size(2) + .with_min_idle(Some(1)), + )) + .map_err(|error| error.to_string())?; + Ok(DbContext { + database_url, + pool, + _database: database, + }) +} + +pub(crate) fn seed_user(url: &str, user_id: Uuid, display_name: &str) -> Result<(), String> { + let mut client = Client::connect(url, NoTls).map_err(|error| format_postgres_error(&error))?; + client + .execute( + "INSERT INTO users (id, display_name) VALUES ($1, $2)", + &[&user_id, &display_name], + ) + .map_err(|error| format_postgres_error(&error)) + .map(|_| ()) +} + +/// Encapsulates user preferences data for test seeding. +pub(crate) struct PreferencesData<'a> { + pub(crate) interest_ids: &'a [&'a str], + pub(crate) safety_ids: &'a [&'a str], + pub(crate) unit_system: &'a str, + pub(crate) revision: i32, +} + +impl<'a> PreferencesData<'a> { + /// Create new preferences data for seeding. + pub(crate) fn new( + interest_ids: &'a [&'a str], + safety_ids: &'a [&'a str], + unit_system: &'a str, + revision: i32, + ) -> Self { + Self { + interest_ids, + safety_ids, + unit_system, + revision, + } + } +} + +pub(crate) fn seed_preferences( + url: &str, + user_id: Uuid, + preferences: PreferencesData<'_>, +) -> Result<(), String> { + let mut client = Client::connect(url, NoTls).map_err(|error| format_postgres_error(&error))?; + let interest_ids = preferences + .interest_ids + .iter() + .map(|value| Uuid::parse_str(value).expect("valid interest UUID")) + .collect::>(); + let safety_ids = preferences + .safety_ids + .iter() + .map(|value| Uuid::parse_str(value).expect("valid safety UUID")) + .collect::>(); + client + .execute( + "INSERT INTO user_preferences ( + user_id, + interest_theme_ids, + safety_toggle_ids, + unit_system, + revision + ) VALUES ($1, $2, $3, $4, $5)", + &[ + &user_id, + &interest_ids, + &safety_ids, + &preferences.unit_system, + &preferences.revision, + ], + ) + .map_err(|error| format_postgres_error(&error)) + .map(|_| ()) +} \ No newline at end of file diff --git a/backend/tests/user_interests_revision_conflicts_bdd/flow_support.rs b/backend/tests/user_interests_revision_conflicts_bdd/flow_support.rs new file mode 100644 index 00000000..67dcc39c --- /dev/null +++ b/backend/tests/user_interests_revision_conflicts_bdd/flow_support.rs @@ -0,0 +1,373 @@ +//! Shared flow and assertion helpers for revision-safe interests BDD coverage. + +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; + +use actix_web::cookie::{Cookie, Key, SameSite}; +use actix_web::{App, test as actix_test, web}; +use backend::domain::ports::{FixtureRouteSubmissionService, RouteSubmissionService}; +use backend::inbound::http::preferences::get_preferences; +use backend::inbound::http::state::HttpState; +use backend::inbound::http::users::{InterestsRequest, LoginRequest, login, update_interests}; +use backend::outbound::persistence::DbPool; +use serde_json::Value; + +use super::support::profile_interests::{FIXTURE_AUTH_ID, build_session_middleware}; +use super::{ServerConfig, state_builders}; + +mod db_support; + +pub(crate) use self::db_support::{ + PreferencesData, World, is_skipped, seed_preferences, seed_user, setup_db_context, +}; + +pub(crate) const FIRST_THEME_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; +pub(crate) const SECOND_THEME_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66afa7"; +pub(crate) const THIRD_THEME_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66afa9"; +pub(crate) const SAFETY_TOGGLE_ID: &str = "7fa85f64-5717-4562-b3fc-2c963f66afa6"; + +#[derive(Debug)] +pub(crate) struct Snapshot { + pub(crate) status: u16, + pub(crate) body: Option, + pub(crate) session_cookie: Option>, +} + +pub(crate) fn run_async(future: impl Future) -> T { + tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(future) +} + +fn parse_json_body(bytes: &[u8]) -> Option { + (!bytes.is_empty()).then(|| serde_json::from_slice(bytes).expect("json body")) +} + +async fn capture_snapshot( + res: actix_web::dev::ServiceResponse, + with_cookie: bool, +) -> Snapshot { + Snapshot { + status: res.status().as_u16(), + session_cookie: with_cookie + .then(|| { + res.response() + .cookies() + .find(|cookie| cookie.name() == "session") + .map(|cookie| cookie.into_owned()) + }) + .flatten(), + body: parse_json_body(actix_test::read_body(res).await.as_ref()), + } +} + +async fn build_app( + state: web::Data, +) -> impl actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, +> { + actix_test::init_service( + App::new().app_data(state).wrap(backend::Trace).service( + web::scope("/api/v1") + .wrap(build_session_middleware()) + .service(login) + .service(update_interests) + .service(get_preferences), + ), + ) + .await +} + +fn build_http_state(pool: DbPool) -> web::Data { + let bind_addr = SocketAddr::from(([127, 0, 0, 1], 0)); + let config = + ServerConfig::new(Key::generate(), false, SameSite::Lax, bind_addr).with_db_pool(pool); + state_builders::build_http_state( + &config, + Arc::new(FixtureRouteSubmissionService) as Arc, + ) +} + +async fn login_cookie(app: &S) -> Cookie<'static> +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, +{ + let login_req = actix_test::TestRequest::post() + .uri("/api/v1/login") + .set_json(&LoginRequest { + username: "admin".to_owned(), + password: "password".to_owned(), + }) + .to_request(); + let login_res = actix_test::call_service(app, login_req).await; + let snapshot = capture_snapshot(login_res, true).await; + snapshot.session_cookie.expect("session cookie") +} + +async fn update_interests_snapshot( + app: &S, + cookie: Cookie<'static>, + payload: InterestsRequest, +) -> Snapshot +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, +{ + let req = actix_test::TestRequest::put() + .uri("/api/v1/users/me/interests") + .cookie(cookie) + .set_json(payload) + .to_request(); + capture_snapshot(actix_test::call_service(app, req).await, false).await +} + +async fn preferences_snapshot(app: &S, cookie: Cookie<'static>) -> Snapshot +where + S: actix_web::dev::Service< + actix_http::Request, + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + >, +{ + let req = actix_test::TestRequest::get() + .uri("/api/v1/users/me/preferences") + .cookie(cookie) + .to_request(); + capture_snapshot(actix_test::call_service(app, req).await, false).await +} + +pub(crate) fn run_first_write(world: &mut World) { + if is_skipped(world) { + return; + } + + let db = world.db.as_ref().expect("db context"); + let state = build_http_state(db.pool.clone()); + world.first_update = Some(run_async(async { + let app = build_app(state).await; + let cookie = login_cookie(&app).await; + update_interests_snapshot( + &app, + cookie, + InterestsRequest { + interest_theme_ids: vec![FIRST_THEME_ID.to_owned()], + expected_revision: None, + }, + ) + .await + })); +} + +pub(crate) fn run_matching_revision_write(world: &mut World) { + if is_skipped(world) { + return; + } + + let db = world.db.as_ref().expect("db context"); + let state = build_http_state(db.pool.clone()); + let (first_update, second_update) = run_async(async { + let app = build_app(state).await; + let cookie = login_cookie(&app).await; + let first = update_interests_snapshot( + &app, + cookie.clone(), + InterestsRequest { + interest_theme_ids: vec![FIRST_THEME_ID.to_owned()], + expected_revision: None, + }, + ) + .await; + let second = update_interests_snapshot( + &app, + cookie, + InterestsRequest { + interest_theme_ids: vec![SECOND_THEME_ID.to_owned()], + expected_revision: Some(1), + }, + ) + .await; + (first, second) + }); + world.first_update = Some(first_update); + world.second_update = Some(second_update); +} + +pub(crate) fn run_stale_revision_conflict(world: &mut World) { + if is_skipped(world) { + return; + } + + let db = world.db.as_ref().expect("db context"); + let state = build_http_state(db.pool.clone()); + world.first_update = Some(run_async(async { + let app = build_app(state).await; + let cookie = login_cookie(&app).await; + update_interests_snapshot( + &app, + cookie, + InterestsRequest { + interest_theme_ids: vec![SECOND_THEME_ID.to_owned()], + expected_revision: Some(1), + }, + ) + .await + })); +} + +pub(crate) fn run_missing_revision_conflict(world: &mut World) { + if is_skipped(world) { + return; + } + + let db = world.db.as_ref().expect("db context"); + let state = build_http_state(db.pool.clone()); + world.first_update = Some(run_async(async { + let app = build_app(state).await; + let cookie = login_cookie(&app).await; + update_interests_snapshot( + &app, + cookie, + InterestsRequest { + interest_theme_ids: vec![SECOND_THEME_ID.to_owned()], + expected_revision: None, + }, + ) + .await + })); +} + +pub(crate) fn run_preserve_non_interest_flow(world: &mut World) { + if is_skipped(world) { + return; + } + + let db = world.db.as_ref().expect("db context"); + let state = build_http_state(db.pool.clone()); + let (update, preferences) = run_async(async { + let app = build_app(state).await; + let cookie = login_cookie(&app).await; + let update = update_interests_snapshot( + &app, + cookie.clone(), + InterestsRequest { + interest_theme_ids: vec![THIRD_THEME_ID.to_owned()], + expected_revision: Some(1), + }, + ) + .await; + let preferences = preferences_snapshot(&app, cookie).await; + (update, preferences) + }); + world.first_update = Some(update); + world.preferences = Some(preferences); +} + +pub(crate) fn assert_interests_snapshot( + snapshot: &Snapshot, + expected_ids: &[&str], + expected_revision: u32, +) { + assert_eq!(snapshot.status, 200); + let body = snapshot.body.as_ref().expect("interests body"); + assert_eq!( + body.get("userId").and_then(Value::as_str), + Some(FIXTURE_AUTH_ID) + ); + assert_eq!( + body.get("interestThemeIds") + .and_then(Value::as_array) + .expect("interestThemeIds array") + .iter() + .map(|value| value.as_str().expect("string interest id")) + .collect::>(), + expected_ids + ); + assert_eq!( + body.get("revision").and_then(Value::as_u64), + Some(u64::from(expected_revision)) + ); +} + +pub(crate) fn assert_conflict_snapshot( + snapshot: &Snapshot, + expected_revision: Option, + actual_revision: u32, +) { + assert_eq!(snapshot.status, 409); + let body = snapshot.body.as_ref().expect("error body"); + assert_eq!(body.get("code").and_then(Value::as_str), Some("conflict")); + assert_eq!( + body.get("message").and_then(Value::as_str), + Some("revision mismatch") + ); + let details = body + .get("details") + .and_then(Value::as_object) + .expect("details object"); + assert_eq!( + details.get("code").and_then(Value::as_str), + Some("revision_mismatch") + ); + match expected_revision { + Some(expected_revision) => assert_eq!( + details.get("expectedRevision").and_then(Value::as_u64), + Some(u64::from(expected_revision)) + ), + None => assert!( + details + .get("expectedRevision") + .is_some_and(serde_json::Value::is_null) + ), + } + assert_eq!( + details.get("actualRevision").and_then(Value::as_u64), + Some(u64::from(actual_revision)) + ); +} + +pub(crate) fn assert_preferences_snapshot( + snapshot: &Snapshot, + expected_interest_ids: &[&str], + expected_safety_ids: &[&str], + expected_unit_system: &str, + expected_revision: u32, +) { + assert_eq!(snapshot.status, 200); + let body = snapshot.body.as_ref().expect("preferences body"); + assert_eq!( + body.get("interestThemeIds") + .and_then(Value::as_array) + .expect("interestThemeIds array") + .iter() + .map(|value| value.as_str().expect("string interest id")) + .collect::>(), + expected_interest_ids + ); + assert_eq!( + body.get("safetyToggleIds") + .and_then(Value::as_array) + .expect("safetyToggleIds array") + .iter() + .map(|value| value.as_str().expect("string safety id")) + .collect::>(), + expected_safety_ids + ); + assert_eq!( + body.get("unitSystem").and_then(Value::as_str), + Some(expected_unit_system) + ); + assert_eq!( + body.get("revision").and_then(Value::as_u64), + Some(u64::from(expected_revision)) + ); +} \ No newline at end of file