Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
45 changes: 41 additions & 4 deletions backend/src/server/state_builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ struct ServicePairFactory<S, Cmd: ?Sized, Query: ?Sized> {
cast: ServiceCast<S, Cmd, Query>,
}

/// User-state ports selected together so DB-present mode cannot drift one port
/// at a time as HTTP state wiring evolves.
struct UserStatePortsBundle {
login: Arc<dyn LoginService>,
users: Arc<dyn UsersQuery>,
profile: Arc<dyn UserProfileQuery>,
interests: Arc<dyn UserInterestsCommand>,
preferences: Arc<dyn UserPreferencesCommand>,
preferences_query: Arc<dyn UserPreferencesQuery>,
route_annotations: Arc<dyn RouteAnnotationsCommand>,
route_annotations_query: Arc<dyn RouteAnnotationsQuery>,
}

pub(super) fn build_login_users_pair_with_pool<Pool>(
pool: &Option<Pool>,
make_pair: impl FnOnce(&Pool) -> (Arc<dyn LoginService>, Arc<dyn UsersQuery>),
Expand Down Expand Up @@ -232,6 +245,24 @@ build_idempotent_pair!(
FixtureRouteAnnotationsQuery
);

fn compose_user_state_ports(config: &ServerConfig) -> UserStatePortsBundle {
let (login, users) = build_login_users_pair(config);
let (profile, interests) = build_profile_interests_pair(config);
let (preferences, preferences_query) = build_user_preferences_pair(config);
let (route_annotations, route_annotations_query) = build_route_annotations_pair(config);

UserStatePortsBundle {
login,
users,
profile,
interests,
preferences,
preferences_query,
route_annotations,
route_annotations_query,
}
}

fn build_offline_bundles_pair(
config: &ServerConfig,
) -> (Arc<dyn OfflineBundleCommand>, Arc<dyn OfflineBundleQuery>) {
Expand Down Expand Up @@ -312,10 +343,16 @@ pub fn build_http_state(
config: &ServerConfig,
route_submission: Arc<dyn RouteSubmissionService>,
) -> web::Data<HttpState> {
let (login, users) = build_login_users_pair(config);
let (profile, interests) = build_profile_interests_pair(config);
let (preferences, preferences_query) = build_user_preferences_pair(config);
let (route_annotations, route_annotations_query) = build_route_annotations_pair(config);
let UserStatePortsBundle {
login,
users,
profile,
interests,
preferences,
preferences_query,
route_annotations,
route_annotations_query,
} = compose_user_state_ports(config);
let (offline_bundles, offline_bundles_query) = build_offline_bundles_pair(config);
let (walk_sessions, walk_sessions_query) = build_walk_sessions_pair(config);
let enrichment_provenance = build_enrichment_provenance_repository(config);
Expand Down
20 changes: 6 additions & 14 deletions backend/tests/startup_mode_composition_bdd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ mod flows;

use db_support::{seed_route, seed_user, setup_db_context};
use flow_support::{
World, assert_internal, assert_profile_response, extract_validation_baseline, is_skipped,
World, assert_internal, assert_user_state_adapter_selection, extract_validation_baseline,
is_skipped,
};
use flows::{run_comprehensive_flow, run_validation_error_flow};

Expand Down Expand Up @@ -82,11 +83,6 @@ fn assert_snapshot_ok(
}

fn assert_user_state_snapshots(world: &World) {
// Users list should return 200
let users_list = world.users_list.as_ref().expect("users_list snapshot");
assert_eq!(users_list.status, 200);

// Preferences should return 200 with all required fields
assert_snapshot_ok(
&world.preferences,
"preferences",
Expand Down Expand Up @@ -162,7 +158,8 @@ fn assert_shared_happy_path_contracts(world: &World, profile_name: &str) {
assert!(login.session_cookie.is_some());

let profile = world.profile.as_ref().expect("profile snapshot");
assert_profile_response(profile, profile_name);
assert_eq!(profile.status, 200);
assert_user_state_adapter_selection(world, profile_name);

assert_user_state_snapshots(world);
assert_catalogue_snapshots(world);
Expand Down Expand Up @@ -268,13 +265,8 @@ fn all_responses_match_db_backed_contracts(world: &mut World) {

assert_shared_happy_path_contracts(world, DB_PROFILE_NAME);

// DB-specific assertion: verify userId matches seeded user
let preferences = world.preferences.as_ref().expect("preferences snapshot");
let prefs_body = preferences.body.as_ref().expect("preferences body");
assert_eq!(
prefs_body.get("userId").and_then(|v| v.as_str()),
Some(FIXTURE_AUTH_ID)
);
// The shared assertion has already verified that user-state endpoints
// expose the seeded DB profile name rather than fixture data.
}

// ------------------------------------------------------------------------
Expand Down
29 changes: 29 additions & 0 deletions backend/tests/startup_mode_composition_bdd/flow_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,32 @@ pub(crate) fn assert_profile_response(snapshot: &Snapshot, expected_display_name
Some(expected_display_name)
);
}

/// Assert that user-state endpoints expose one deterministic adapter choice.
pub(crate) fn assert_user_state_adapter_selection(world: &World, expected_display_name: &str) {
let profile = world.profile.as_ref().expect("profile snapshot");
assert_profile_response(profile, expected_display_name);

let users_list = world.users_list.as_ref().expect("users_list snapshot");
assert_eq!(users_list.status, 200);
let users = users_list
.body
.as_ref()
.and_then(|body| body.get("data"))
.and_then(Value::as_array)
.expect("users data array");
assert!(
users.iter().any(|user| {
user.get("displayName").and_then(Value::as_str) == Some(expected_display_name)
}),
"users list should expose the selected user-state adapter data"
);

let preferences = world.preferences.as_ref().expect("preferences snapshot");
assert_eq!(preferences.status, 200);
let preferences_body = preferences.body.as_ref().expect("preferences body");
assert_eq!(
preferences_body.get("userId").and_then(Value::as_str),
Some(FIXTURE_AUTH_ID)
);
}
9 changes: 8 additions & 1 deletion docs/backend-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,17 @@ so persistence details stay confined to outbound adapters.
- [x] 3.5.4. Define and implement the revision-safe interests update strategy
(for example optimistic concurrency via expected revision checks), including
the persistence contract and error mapping for stale-write conflicts.
- [ ] 3.5.5. Harden `backend/src/server/state_builders.rs` startup-mode
- [x] 3.5.5. Harden `backend/src/server/state_builders.rs` startup-mode
composition with explicit helper seams and regression assertions so
DB-present versus fixture-fallback adapter selection remains deterministic as
user-state wiring evolves.
- Execution note (2026-05-26): implementation added the private
`compose_user_state_ports` helper seam and `UserStatePortsBundle`, then
strengthened `backend/tests/startup_mode_composition_bdd.rs` with
adapter-selection assertions at the HTTP boundary. `make check-fmt`,
`make lint`, `make test`, and CodeRabbit agent review passed before
closure; evidence is tracked in
`docs/execplans/backend-3-5-5-harden-startup-mode-composition.md`.
- [ ] 3.5.6. Expand behavioural and repository-level regression coverage for the
full login/users/profile/interests startup matrix, and include
revision-conflict interests scenarios after 3.5.4 lands.
Expand Down
50 changes: 26 additions & 24 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,17 @@ When adding a new behaviour:
When migrating existing suites, prefer incremental edits that preserve scenario
intent and avoid broad rewrites that obscure regressions.

When validating generated `rstest-bdd` integration binaries, prefer running
the binary directly:

```bash
cargo test -p backend --test startup_mode_composition_bdd -- --nocapture
```

Name filters can miss generated scenario functions or select only support
module tests. Use filters only after confirming the generated scenario names
match the filter text.

## Shared workspace crate testing

Shared workspace crates (such as `backend/crates/pagination`) provide
Expand Down Expand Up @@ -467,26 +478,15 @@ Related domain helpers:
- `RouteCacheKeyDerivationError` reports `Hash` and `Validation` failures from
key derivation.

### Test infrastructure

The Redis adapter test suite uses a dual-mode approach:

**Mock-based unit tests** (run by default):

- Located in `backend/src/outbound/cache/tests/mock_tests.rs`
- Use `FakeProvider` – an in-memory `ConnectionProvider` double
- Fast, deterministic, no external dependencies
- Run as part of the standard `cargo test` / `make test` gate
#### Test infrastructure

**Live Redis integration tests** (opt-in):
- `pg-embedded-setup-unpriv` – Embedded PostgreSQL cluster for BDD tests
- No feature flags required; BDD tests are in the `tests/` integration
harness and run unconditionally with `cargo test`

- Located in `backend/src/outbound/cache/tests/live_tests.rs`
- Require a `redis-server` binary on `PATH`
- Marked with `#[ignore = "requires redis-server binary..."]`
- Run explicitly with: `cargo test -- --ignored`
- Behavioural coverage for route-key canonicalization lives in
`backend/tests/route_cache_key_canonicalization_bdd.rs`.
To run BDD tests locally:

```bash
### RedisTestServer harness

Integration tests use `RedisTestServer` from `backend/src/test_support/redis.rs`:
Expand Down Expand Up @@ -520,17 +520,19 @@ The cache adapter requires:

#### Production dependencies

- `bb8-redis` – Connection pooling for `redis-rs`
- `serde` / `serde_json` – Payload serialization
- `apalis-core` – Core Apalis job-queue primitives
- `apalis-postgres` – PostgreSQL storage backend for Apalis
- `sqlx` (features: `postgres`, `runtime-tokio-rustls`) – Async PostgreSQL
pool used by `ApalisPostgresProvider`
- `serde` / `serde_json` – Payload serialisation

#### Test infrastructure

- `test-support` feature flag – Enables `RedisRouteCache::new()` constructor
and `RedisTestServer::pool()` for test injection
- `redis-server` binary – Required for live integration tests (not for unit
tests using `FakeProvider`)
- `pg-embedded-setup-unpriv` – Embedded PostgreSQL cluster for BDD tests
- No feature flags required; BDD tests are in the `tests/` integration
harness and run unconditionally with `cargo test`

To run live Redis tests locally:
To run BDD tests locally:

```bash
# Ensure redis-server is available
Expand Down
Loading
Loading