From 44c9afd7d89f17596992a76ccebe28e861a298e5 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Tue, 8 Sep 2026 18:55:06 +0000 Subject: [PATCH 01/19] Initial framework for describing test matrix --- sdk/cosmos/azure_data_cosmos/Cargo.toml | 5 + sdk/cosmos/azure_data_cosmos/build.rs | 2 +- .../tests/e2e_test_cases/catalog.rs | 1080 +++++++++++++++++ .../tests/e2e_test_cases/fixture.rs | 130 ++ .../tests/e2e_test_cases/mod.rs | 695 +++++++++++ .../azure_data_cosmos/tests/e2e_tests.rs | 6 + .../src/options/read_consistency.rs | 7 +- .../azure_data_cosmos_emulator/README.md | 3 + .../src/management.rs | 89 ++ sdk/cosmos/ci.yml | 14 + sdk/cosmos/e2e-consistency-matrix.json | 25 + .../e2e-read-consistency-override-matrix.json | 24 + sdk/cosmos/e2e_tests/README.md | 120 ++ .../e2e_tests/implementations/rust.json | 17 + .../profiles/hostedEmulatorSmoke.json | 30 + .../e2e_tests/profiles/legacyGatewayV1.json | 30 + .../profiles/lifecycleConsistencyMatrix.json | 49 + .../readConsistencyOverrideMatrix.json | 22 + .../e2e_tests/profiles/targetDefault.json | 33 + .../scenarios/bootstrap/primary-success.json | 71 ++ .../diagnostics/success-and-error.json | 148 +++ .../scenarios/items/create-conflict.json | 201 +++ .../e2e_tests/scenarios/items/lifecycle.json | 600 +++++++++ .../items/not-found-wrong-partition-key.json | 134 ++ .../items/optimistic-concurrency.json | 126 ++ .../scenarios/items/upsert-create-update.json | 122 ++ .../scenarios/management/capabilities.json | 77 ++ .../scenarios/queries/invalid-syntax.json | 74 ++ .../queries/parameterized-filter.json | 125 ++ sdk/cosmos/e2e_tests/schema/profile.v1.json | 99 ++ sdk/cosmos/e2e_tests/schema/scenario.v1.json | 446 +++++++ sdk/cosmos/e2e_tests/vocabulary/v1.json | 18 + .../eng/scripts/Invoke-CosmosTestSetup.ps1 | 102 +- 33 files changed, 4719 insertions(+), 5 deletions(-) create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_tests.rs create mode 100644 sdk/cosmos/e2e-consistency-matrix.json create mode 100644 sdk/cosmos/e2e-read-consistency-override-matrix.json create mode 100644 sdk/cosmos/e2e_tests/README.md create mode 100644 sdk/cosmos/e2e_tests/implementations/rust.json create mode 100644 sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json create mode 100644 sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json create mode 100644 sdk/cosmos/e2e_tests/profiles/lifecycleConsistencyMatrix.json create mode 100644 sdk/cosmos/e2e_tests/profiles/readConsistencyOverrideMatrix.json create mode 100644 sdk/cosmos/e2e_tests/profiles/targetDefault.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/management/capabilities.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json create mode 100644 sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json create mode 100644 sdk/cosmos/e2e_tests/schema/profile.v1.json create mode 100644 sdk/cosmos/e2e_tests/schema/scenario.v1.json create mode 100644 sdk/cosmos/e2e_tests/vocabulary/v1.json diff --git a/sdk/cosmos/azure_data_cosmos/Cargo.toml b/sdk/cosmos/azure_data_cosmos/Cargo.toml index 863b6312a5b..8b40ba7c7f5 100644 --- a/sdk/cosmos/azure_data_cosmos/Cargo.toml +++ b/sdk/cosmos/azure_data_cosmos/Cargo.toml @@ -170,6 +170,11 @@ name = "in_memory_emulator" path = "tests/in_memory_emulator.rs" required-features = ["__internal_in_memory_emulator"] +[[test]] +name = "e2e_tests" +path = "tests/e2e_tests.rs" +required-features = ["key_auth", "control_plane"] + # The `cosmos` example CLI exercises control-plane operations (database and # container CRUD, throughput) alongside data-plane commands, so the whole # binary requires the `control_plane` feature to compile. diff --git a/sdk/cosmos/azure_data_cosmos/build.rs b/sdk/cosmos/azure_data_cosmos/build.rs index 7245cc3cbd5..2c498f981c2 100644 --- a/sdk/cosmos/azure_data_cosmos/build.rs +++ b/sdk/cosmos/azure_data_cosmos/build.rs @@ -7,6 +7,6 @@ fn main() { // Allow `#[cfg_attr(not(test_category = "..."), ignore)]` in `tests/*.rs`. println!( - "cargo:rustc-check-cfg=cfg(test_category, values(\"emulator\", \"emulator_vnext\", \"emulator_inmemory\", \"emulator_inmemory_gateway_v2\", \"multi_write\", \"split\", \"binary_encoding\", \"gateway_v2\", \"gateway_v2_multi_region\"))" + "cargo:rustc-check-cfg=cfg(test_category, values(\"emulator\", \"emulator_vnext\", \"emulator_inmemory\", \"emulator_inmemory_gateway_v2\", \"e2e\", \"multi_write\", \"split\", \"binary_encoding\", \"gateway_v2\", \"gateway_v2_multi_region\"))" ); } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs new file mode 100644 index 00000000000..88d8361f967 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs @@ -0,0 +1,1080 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#![allow(dead_code)] + +use std::{ + borrow::Cow, + collections::{BTreeMap, BTreeSet}, +}; + +use azure_data_cosmos::{ + diagnostics::DiagnosticsContext, + models::{PartitionKeyDefinition, PartitionKeyKind, PartitionKeyValue, PartitionKeyVersion}, + PartitionKey, +}; +use serde::Deserialize; +use serde_json::Value; + +const SCENARIOS: &[&str] = &[ + include_str!("../../../e2e_tests/scenarios/management/capabilities.json"), + include_str!("../../../e2e_tests/scenarios/bootstrap/primary-success.json"), + include_str!("../../../e2e_tests/scenarios/items/lifecycle.json"), + include_str!("../../../e2e_tests/scenarios/items/upsert-create-update.json"), + include_str!("../../../e2e_tests/scenarios/items/create-conflict.json"), + include_str!("../../../e2e_tests/scenarios/items/not-found-wrong-partition-key.json"), + include_str!("../../../e2e_tests/scenarios/items/optimistic-concurrency.json"), + include_str!("../../../e2e_tests/scenarios/queries/parameterized-filter.json"), + include_str!("../../../e2e_tests/scenarios/queries/invalid-syntax.json"), + include_str!("../../../e2e_tests/scenarios/diagnostics/success-and-error.json"), +]; + +const PROFILES: &[&str] = &[ + include_str!("../../../e2e_tests/profiles/hostedEmulatorSmoke.json"), + include_str!("../../../e2e_tests/profiles/targetDefault.json"), + include_str!("../../../e2e_tests/profiles/legacyGatewayV1.json"), + include_str!("../../../e2e_tests/profiles/lifecycleConsistencyMatrix.json"), + include_str!("../../../e2e_tests/profiles/readConsistencyOverrideMatrix.json"), +]; + +const VOCABULARY: &str = include_str!("../../../e2e_tests/vocabulary/v1.json"); +const RUST_IMPLEMENTATIONS: &str = include_str!("../../../e2e_tests/implementations/rust.json"); +const SCENARIO_SCHEMA: &str = include_str!("../../../e2e_tests/schema/scenario.v1.json"); +const PROFILE_SCHEMA: &str = include_str!("../../../e2e_tests/schema/profile.v1.json"); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Scenario { + #[serde(rename = "$schema")] + schema: String, + spec_version: String, + pub id: String, + title: String, + requirement: String, + maturity: String, + precedents: Vec, + profile: Option, + tags: Vec, + backends: BTreeMap, + pub fixtures: Vec, + #[serde(default)] + pub executions: Vec, + pub steps: Vec, + cleanup: Cleanup, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Precedent { + sdk: String, + path: String, + test: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Backend { + applicability: String, + fidelity: String, + reason: Option, + #[serde(default)] + requires: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fixture { + pub id: String, + container: ContainerSetup, + pub items: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ContainerSetup { + partition_key: PartitionKeySetup, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PartitionKeySetup { + paths: Vec, + kind: String, + version: u8, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FixtureItem { + pub id: String, + pub seed: bool, + partition_key_values: Vec, + pub document: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Execution { + pub id: String, + pub profile: String, + pub account: String, + pub runtime: String, + pub client: String, + pub read_consistency_strategy: String, + pub read_region: String, + pub session_token: String, + pub expected_read: ExpectedRead, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExpectedRead { + pub acceptable_initial_statuses: Vec, + pub terminal_status: ExpectedStatus, + pub max_wait_ms: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExpectedStatus { + pub status_code: u16, + pub sub_status_code: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Step { + pub id: String, + action: Action, + pub expected: Expected, + pub diagnostics: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Action { + kind: String, + operation: String, + input: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Expected { + outcome: String, + pub status: u16, + pub sub_status: Option, + error_category: Option, + state: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DiagnosticsExpectation { + operation_name: Option, + activity_id: Option, + effective_status: Option, + request_count: Option, + regions_contacted: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Comparison { + comparator: String, + value: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Profile { + #[serde(rename = "$schema")] + schema: String, + spec_version: String, + pub id: String, + pub accounts: Vec, + pub runtimes: Vec, + pub clients: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccountDefinition { + pub id: String, + write_mode: String, + pub consistency: String, + regions: Vec, + replication: ReplicationDefinition, + per_partition_failover: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RegionDefinition { + name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReplicationDefinition { + min_delay_ms: u64, + max_delay_ms: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeDefinition { + pub id: String, + gateway_v2: String, + ppcb: String, + pub default_read_consistency_strategy: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClientDefinition { + pub id: String, + binary_encoding: String, + routing: String, + pub default_read_consistency_strategy: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Vocabulary { + spec_version: String, + backends: Vec, + applicability: Vec, + maturity: Vec, + operations: Vec, + error_categories: Vec, + diagnostic_comparators: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ImplementationMap { + spec_version: String, + sdk: String, + test_target: String, + scenarios: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Implementation { + id: String, + test: String, + status: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Cleanup { + delete_database: bool, +} + +pub fn load_scenarios() -> Result, String> { + SCENARIOS + .iter() + .map(|json| serde_json::from_str(json).map_err(|error| error.to_string())) + .collect() +} + +pub fn scenario(id: &str) -> Scenario { + load_scenarios() + .expect("E2E scenario catalog must deserialize") + .into_iter() + .find(|scenario| scenario.id == id) + .unwrap_or_else(|| panic!("E2E scenario '{id}' does not exist")) +} + +pub fn profile(id: &str) -> Profile { + PROFILES + .iter() + .map(|json| serde_json::from_str::(json).expect("E2E profile must deserialize")) + .find(|profile| profile.id == id) + .unwrap_or_else(|| panic!("E2E profile '{id}' does not exist")) +} + +impl Scenario { + pub fn step(&self, id: &str) -> &Step { + self.steps + .iter() + .find(|step| step.id == id) + .unwrap_or_else(|| panic!("scenario '{}' has no step '{id}'", self.id)) + } + + pub fn fixture(&self, id: &str) -> &Fixture { + self.fixtures + .iter() + .find(|fixture| fixture.id == id) + .unwrap_or_else(|| panic!("scenario '{}' has no fixture '{id}'", self.id)) + } + + pub fn executions_for_configuration<'a>( + &'a self, + profile: &'a str, + account: &'a str, + runtime: &'a str, + client: &'a str, + ) -> impl Iterator + 'a { + self.executions.iter().filter(move |execution| { + execution.profile == profile + && execution.account == account + && execution.runtime == runtime + && execution.client == client + }) + } +} + +impl Profile { + pub fn account(&self, id: &str) -> &AccountDefinition { + self.accounts + .iter() + .find(|definition| definition.id == id) + .unwrap_or_else(|| panic!("profile '{}' has no account '{id}'", self.id)) + } + + pub fn runtime(&self, id: &str) -> &RuntimeDefinition { + self.runtimes + .iter() + .find(|definition| definition.id == id) + .unwrap_or_else(|| panic!("profile '{}' has no runtime '{id}'", self.id)) + } + + pub fn client(&self, id: &str) -> &ClientDefinition { + self.clients + .iter() + .find(|definition| definition.id == id) + .unwrap_or_else(|| panic!("profile '{}' has no client '{id}'", self.id)) + } +} + +impl Fixture { + pub fn item(&self, id: &str) -> &FixtureItem { + self.items + .iter() + .find(|item| item.id == id) + .unwrap_or_else(|| panic!("fixture '{}' has no item '{id}'", self.id)) + } + + pub fn partition_key_definition(&self) -> Result { + let kind = match self.container.partition_key.kind.as_str() { + "Hash" => PartitionKeyKind::Hash, + "MultiHash" => PartitionKeyKind::MultiHash, + kind => { + return Err(format!( + "fixture '{}' has unknown PK kind '{kind}'", + self.id + )) + } + }; + let version = match self.container.partition_key.version { + 1 => PartitionKeyVersion::V1, + 2 => PartitionKeyVersion::V2, + version => return Err(format!("fixture '{}' has PK version {version}", self.id)), + }; + Ok(PartitionKeyDefinition::new( + self.container + .partition_key + .paths + .iter() + .cloned() + .map(Cow::Owned) + .collect(), + ) + .with_kind(kind) + .with_version(version)) + } +} + +impl FixtureItem { + pub fn partition_key(&self) -> Result { + let values = self + .partition_key_values + .iter() + .map(partition_key_value) + .collect::, _>>()?; + Ok(PartitionKey::from(values)) + } + + pub fn document_id(&self) -> Result<&str, String> { + self.document + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| format!("fixture item '{}' has no string document id", self.id)) + } +} + +impl ExpectedStatus { + pub fn matches(&self, status_code: u16, sub_status_code: Option) -> bool { + self.status_code == status_code + && self + .sub_status_code + .is_none_or(|expected| expected == sub_status_code.unwrap_or(0)) + } + + fn overlaps(&self, other: &Self) -> bool { + self.status_code == other.status_code + && (self.sub_status_code.is_none() + || other.sub_status_code.is_none() + || self.sub_status_code == other.sub_status_code) + } +} + +impl ExpectedRead { + pub fn is_terminal(&self, status_code: u16, sub_status_code: Option) -> bool { + self.terminal_status.matches(status_code, sub_status_code) + } + + pub fn is_acceptable_initial(&self, status_code: u16, sub_status_code: Option) -> bool { + self.acceptable_initial_statuses + .iter() + .any(|expected| expected.matches(status_code, sub_status_code)) + } +} + +fn partition_key_value(value: &Value) -> Result { + match value { + Value::String(value) => Ok(value.clone().into()), + Value::Number(value) => value + .as_f64() + .map(PartitionKeyValue::from) + .ok_or_else(|| format!("partition key number '{value}' is not finite")), + Value::Bool(value) => Ok((*value).into()), + Value::Null => Ok(PartitionKey::NULL), + value => Err(format!("unsupported partition key value '{value}'")), + } +} + +pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { + let vocabulary: Vocabulary = + serde_json::from_str(VOCABULARY).map_err(|error| error.to_string())?; + if vocabulary.spec_version != "1.0" { + return Err("unsupported vocabulary version".to_owned()); + } + + for (name, schema) in [("scenario", SCENARIO_SCHEMA), ("profile", PROFILE_SCHEMA)] { + let schema: Value = serde_json::from_str(schema) + .map_err(|error| format!("{name} schema is not JSON: {error}"))?; + if schema.get("$schema").and_then(Value::as_str) + != Some("https://json-schema.org/draft/2020-12/schema") + { + return Err(format!("{name} schema must use JSON Schema draft 2020-12")); + } + } + + let profiles: Vec = PROFILES + .iter() + .map(|json| serde_json::from_str(json).map_err(|error| error.to_string())) + .collect::>()?; + let mut profile_ids = BTreeSet::new(); + for profile in &profiles { + if profile.spec_version != "1.0" || !profile_ids.insert(profile.id.as_str()) { + return Err(format!("invalid or duplicate profile '{}'", profile.id)); + } + for (axis, ids) in [ + ( + "account", + profile + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect::>(), + ), + ( + "runtime", + profile + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect::>(), + ), + ( + "client", + profile + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect::>(), + ), + ] { + let unique: BTreeSet<_> = ids.iter().copied().collect(); + if ids.is_empty() || unique.len() != ids.len() { + return Err(format!( + "profile '{}' has an empty or duplicate {axis} axis", + profile.id + )); + } + } + for account in &profile.accounts { + if account.regions.is_empty() + || account.replication.min_delay_ms > account.replication.max_delay_ms + { + return Err(format!( + "profile '{}' account '{}' has invalid regions or replication delay", + profile.id, account.id + )); + } + } + } + + let scenarios = load_scenarios()?; + let mut scenario_ids = BTreeSet::new(); + for scenario in &scenarios { + if scenario.spec_version != "1.0" { + return Err(format!( + "scenario '{}' has an unsupported version", + scenario.id + )); + } + if !scenario_ids.insert(scenario.id.as_str()) { + return Err(format!("duplicate scenario id '{}'", scenario.id)); + } + if let Some(profile) = &scenario.profile { + if !profile_ids.contains(profile.as_str()) { + return Err(format!( + "scenario '{}' references unknown profile '{}'", + scenario.id, profile + )); + } + } + if scenario.profile.is_some() != scenario.executions.is_empty() { + return Err(format!( + "scenario '{}' must declare either one profile or an execution matrix", + scenario.id + )); + } + if !vocabulary.maturity.contains(&scenario.maturity) { + return Err(format!("scenario '{}' has unknown maturity", scenario.id)); + } + if scenario.precedents.is_empty() + || scenario.tags.is_empty() + || scenario.fixtures.is_empty() + || scenario.steps.is_empty() + { + return Err(format!( + "scenario '{}' is missing required evidence", + scenario.id + )); + } + let mut fixture_ids = BTreeSet::new(); + for fixture in &scenario.fixtures { + if !fixture_ids.insert(fixture.id.as_str()) { + return Err(format!( + "scenario '{}' has duplicate fixture '{}'", + scenario.id, fixture.id + )); + } + let definition = fixture.partition_key_definition()?; + let path_count = definition.paths().len(); + if path_count == 0 + || (definition.kind() == PartitionKeyKind::Hash && path_count != 1) + || (definition.kind() == PartitionKeyKind::MultiHash + && (path_count < 2 || definition.version() != PartitionKeyVersion::V2)) + { + return Err(format!( + "scenario '{}' fixture '{}' has an invalid partition key definition", + scenario.id, fixture.id + )); + } + let mut item_ids = BTreeSet::new(); + for item in &fixture.items { + if !item_ids.insert(item.id.as_str()) { + return Err(format!( + "scenario '{}' fixture '{}' has duplicate item '{}'", + scenario.id, fixture.id, item.id + )); + } + if item.partition_key_values.len() != path_count { + return Err(format!( + "scenario '{}' fixture '{}' item '{}' has {} partition-key values for {} paths", + scenario.id, + fixture.id, + item.id, + item.partition_key_values.len(), + path_count + )); + } + item.partition_key()?; + item.document_id()?; + } + } + let mut execution_ids = BTreeSet::new(); + for execution in &scenario.executions { + if !execution_ids.insert(execution.id.as_str()) { + return Err(format!( + "scenario '{}' has duplicate execution '{}'", + scenario.id, execution.id + )); + } + if !profile_ids.contains(execution.profile.as_str()) { + return Err(format!( + "scenario '{}' execution '{}' references unknown profile '{}'", + scenario.id, execution.id, execution.profile + )); + } + let selected_profile = profiles + .iter() + .find(|profile| profile.id == execution.profile) + .expect("profile existence checked above"); + if !selected_profile + .accounts + .iter() + .any(|definition| definition.id == execution.account) + || !selected_profile + .runtimes + .iter() + .any(|definition| definition.id == execution.runtime) + || !selected_profile + .clients + .iter() + .any(|definition| definition.id == execution.client) + { + return Err(format!( + "scenario '{}' execution '{}' references a missing profile matrix cell", + scenario.id, execution.id + )); + } + let expected = &execution.expected_read; + let unique_initial_statuses: BTreeSet<_> = + expected.acceptable_initial_statuses.iter().collect(); + if unique_initial_statuses.len() != expected.acceptable_initial_statuses.len() + || expected + .acceptable_initial_statuses + .iter() + .enumerate() + .any(|(index, status)| { + expected.acceptable_initial_statuses[index + 1..] + .iter() + .any(|other| status.overlaps(other)) + }) + || expected + .acceptable_initial_statuses + .iter() + .chain(std::iter::once(&expected.terminal_status)) + .any(|status| !(100..=599).contains(&status.status_code)) + || expected + .acceptable_initial_statuses + .iter() + .any(|status| status.overlaps(&expected.terminal_status)) + { + return Err(format!( + "scenario '{}' execution '{}' has invalid or overlapping status expectations", + scenario.id, execution.id + )); + } + if expected.acceptable_initial_statuses.is_empty() { + if expected.max_wait_ms.is_some() { + return Err(format!( + "scenario '{}' execution '{}' declares a wait without transient statuses", + scenario.id, execution.id + )); + } + } else if expected.max_wait_ms.is_none() { + return Err(format!( + "scenario '{}' execution '{}' must bound retries for transient statuses", + scenario.id, execution.id + )); + } + let selected_account = selected_profile + .accounts + .iter() + .find(|definition| definition.id == execution.account) + .expect("account existence checked above"); + if execution.read_consistency_strategy == "GlobalStrong" + && selected_account.consistency != "strong" + && (!expected.acceptable_initial_statuses.is_empty() + || !expected.is_terminal(400, None)) + { + return Err(format!( + "scenario '{}' execution '{}' must reject GlobalStrong on a non-Strong profile", + scenario.id, execution.id + )); + } + } + if scenario.id == "item.lifecycle" { + let required_strategies: BTreeSet<_> = [ + "Default", + "Eventual", + "Session", + "LatestCommitted", + "GlobalStrong", + ] + .into_iter() + .collect(); + for account in [ + "strong", + "boundedStaleness", + "session", + "consistentPrefix", + "eventual", + ] { + let actual: BTreeSet<_> = scenario + .executions_for_configuration( + "lifecycleConsistencyMatrix", + account, + "unset", + "unset", + ) + .map(|execution| execution.read_consistency_strategy.as_str()) + .collect(); + if actual != required_strategies { + return Err(format!( + "item.lifecycle account '{account}' must cover every read consistency strategy; got {actual:?}" + )); + } + } + let override_profile = profiles + .iter() + .find(|profile| profile.id == "readConsistencyOverrideMatrix") + .expect("override profile must exist"); + for runtime in &override_profile.runtimes { + for client in &override_profile.clients { + let actual: BTreeSet<_> = scenario + .executions_for_configuration( + "readConsistencyOverrideMatrix", + "session", + &runtime.id, + &client.id, + ) + .map(|execution| execution.read_consistency_strategy.as_str()) + .collect(); + let required: BTreeSet<_> = + ["Inherit", "Default", "Eventual"].into_iter().collect(); + if actual != required { + return Err(format!( + "item.lifecycle override cell runtime='{}' client='{}' has incomplete operation coverage: {actual:?}", + runtime.id, client.id + )); + } + } + } + } + if scenario.backends.len() != vocabulary.backends.len() { + return Err(format!( + "scenario '{}' has incomplete backend applicability", + scenario.id + )); + } + for backend_name in &vocabulary.backends { + let backend = scenario.backends.get(backend_name).ok_or_else(|| { + format!("scenario '{}' omits backend '{backend_name}'", scenario.id) + })?; + if !vocabulary.applicability.contains(&backend.applicability) { + return Err(format!( + "scenario '{}' has unknown applicability", + scenario.id + )); + } + if backend.applicability == "notApplicable" && backend.reason.is_none() { + return Err(format!( + "scenario '{}' must explain why '{backend_name}' is not applicable", + scenario.id + )); + } + } + + let mut step_ids = BTreeSet::new(); + for step in &scenario.steps { + if !step_ids.insert(step.id.as_str()) { + return Err(format!( + "scenario '{}' has duplicate step '{}'", + scenario.id, step.id + )); + } + if !vocabulary.operations.contains(&step.action.operation) { + return Err(format!( + "scenario '{}' uses unknown operation '{}'", + scenario.id, step.action.operation + )); + } + for item_ref in [ + step.action + .input + .as_ref() + .and_then(|input| input.get("itemRef")) + .and_then(Value::as_str), + step.expected + .state + .as_ref() + .and_then(|state| state.get("itemRef")) + .and_then(Value::as_str), + ] + .into_iter() + .flatten() + { + for fixture in &scenario.fixtures { + if !fixture.items.iter().any(|item| item.id == item_ref) { + return Err(format!( + "scenario '{}' step '{}' references missing item '{}' in fixture '{}'", + scenario.id, step.id, item_ref, fixture.id + )); + } + } + } + if step.expected.outcome == "error" + && step + .expected + .error_category + .as_ref() + .is_some_and(|category| !vocabulary.error_categories.contains(category)) + { + return Err(format!( + "scenario '{}' has unknown error category", + scenario.id + )); + } + if let Some(diagnostics) = &step.diagnostics { + for comparison in diagnostics.comparisons() { + if !vocabulary + .diagnostic_comparators + .contains(&comparison.comparator) + { + return Err(format!( + "scenario '{}' uses unknown diagnostics comparator '{}'", + scenario.id, comparison.comparator + )); + } + let needs_value = + !matches!(comparison.comparator.as_str(), "present" | "absent"); + if needs_value != comparison.value.is_some() { + return Err(format!( + "scenario '{}' comparator '{}' has an invalid value", + scenario.id, comparison.comparator + )); + } + } + } + } + } + + let implementations: ImplementationMap = + serde_json::from_str(RUST_IMPLEMENTATIONS).map_err(|error| error.to_string())?; + if implementations.spec_version != "1.0" + || implementations.sdk != "rust" + || implementations.test_target != "e2e_tests" + { + return Err("invalid Rust implementation map header".to_owned()); + } + let known_tests: BTreeSet<_> = implemented_tests.iter().copied().collect(); + let mut mapped_ids = BTreeSet::new(); + for implementation in &implementations.scenarios { + if !scenario_ids.contains(implementation.id.as_str()) { + return Err(format!( + "implementation references unknown scenario '{}'", + implementation.id + )); + } + if !mapped_ids.insert(implementation.id.as_str()) { + return Err(format!( + "scenario '{}' is mapped more than once", + implementation.id + )); + } + if implementation.status == "active" && !known_tests.contains(implementation.test.as_str()) + { + return Err(format!( + "scenario '{}' references missing test '{}'", + implementation.id, implementation.test + )); + } + } + if mapped_ids != scenario_ids { + return Err("every scenario must have exactly one Rust implementation mapping".to_owned()); + } + Ok(()) +} + +impl DiagnosticsExpectation { + fn comparisons(&self) -> impl Iterator { + [ + self.operation_name.as_ref(), + self.activity_id.as_ref(), + self.effective_status.as_ref(), + self.request_count.as_ref(), + self.regions_contacted.as_ref(), + ] + .into_iter() + .flatten() + } +} + +pub fn assert_status(step: &Step, actual: u16, sub_status: Option) { + assert_eq!( + actual, step.expected.status, + "status for step '{}'", + step.id + ); + if let Some(expected) = step.expected.sub_status { + assert_eq!( + sub_status.unwrap_or(0), + expected, + "substatus for step '{}'", + step.id + ); + } +} + +pub fn assert_diagnostics(step: &Step, diagnostics: &DiagnosticsContext) { + let Some(contract) = &step.diagnostics else { + return; + }; + if let Some(expected) = &contract.operation_name { + assert_string( + expected, + diagnostics.operation_name(), + "operationName", + &step.id, + ); + } + if let Some(expected) = &contract.activity_id { + let actual = diagnostics.activity_id().to_string(); + assert_string(expected, Some(actual.as_str()), "activityId", &step.id); + } + if let Some(expected) = &contract.effective_status { + let actual = diagnostics + .effective_status() + .map(|status| u16::from(status.status_code())); + assert_number(expected, actual.map(u64::from), "effectiveStatus", &step.id); + } + if let Some(expected) = &contract.request_count { + assert_number( + expected, + Some(diagnostics.request_count() as u64), + "requestCount", + &step.id, + ); + } + if let Some(expected) = &contract.regions_contacted { + let contacted_regions = diagnostics.regions_contacted(); + let regions: Vec<_> = contacted_regions + .iter() + .map(|region| region.as_str()) + .collect(); + let expected_regions = expected + .value + .as_ref() + .and_then(Value::as_array) + .expect("region comparison requires an array"); + if expected.comparator == "contains" { + for region in expected_regions { + let region = region.as_str().expect("region must be a string"); + assert!( + regions.contains(®ion), + "diagnostics field regionsContacted for step '{}' did not contain '{region}': {regions:?}", + step.id + ); + } + } + } +} + +fn assert_string(expected: &Comparison, actual: Option<&str>, field: &str, step: &str) { + match expected.comparator.as_str() { + "present" => assert!( + actual.is_some_and(|value| !value.is_empty()), + "{field} for step '{step}' must be present" + ), + "absent" => assert!(actual.is_none(), "{field} for step '{step}' must be absent"), + "exact" => assert_eq!( + actual, + expected.value.as_ref().and_then(Value::as_str), + "diagnostics field {field} for step '{step}'" + ), + comparator => panic!("unsupported string comparator '{comparator}' for {field}"), + } +} + +fn assert_number(expected: &Comparison, actual: Option, field: &str, step: &str) { + let value = expected.value.as_ref().and_then(Value::as_u64); + match expected.comparator.as_str() { + "present" => assert!( + actual.is_some(), + "{field} for step '{step}' must be present" + ), + "absent" => assert!(actual.is_none(), "{field} for step '{step}' must be absent"), + "exact" => assert_eq!(actual, value, "diagnostics field {field} for step '{step}'"), + "atLeast" => assert!( + actual.zip(value).is_some_and(|(actual, expected)| actual >= expected), + "diagnostics field {field} for step '{step}' must be at least {value:?}, got {actual:?}" + ), + "atMost" => { + assert!( + actual.zip(value).is_some_and(|(actual, expected)| actual <= expected), + "diagnostics field {field} for step '{step}' must be at most {value:?}, got {actual:?}" + ) + } + comparator => panic!("unsupported numeric comparator '{comparator}' for {field}"), + } +} + +#[cfg(test)] +mod tests { + use super::{ExpectedRead, ExpectedStatus}; + + #[test] + fn status_without_expected_substatus_matches_any_substatus() { + let status = ExpectedStatus { + status_code: 200, + sub_status_code: None, + }; + + assert!(status.matches(200, None)); + assert!(status.matches(200, Some(1002))); + assert!(!status.matches(404, None)); + } + + #[test] + fn explicit_zero_substatus_matches_missing_response_substatus() { + let status = ExpectedStatus { + status_code: 404, + sub_status_code: Some(0), + }; + + assert!(status.matches(404, None)); + assert!(status.matches(404, Some(0))); + assert!(!status.matches(404, Some(1002))); + } + + #[test] + fn status_overlap_accounts_for_wildcard_substatus() { + let wildcard = ExpectedStatus { + status_code: 404, + sub_status_code: None, + }; + let explicit = ExpectedStatus { + status_code: 404, + sub_status_code: Some(1002), + }; + let other_status = ExpectedStatus { + status_code: 200, + sub_status_code: None, + }; + + assert!(wildcard.overlaps(&explicit)); + assert!(explicit.overlaps(&wildcard)); + assert!(!wildcard.overlaps(&other_status)); + } + + #[test] + fn read_expectation_distinguishes_transient_and_terminal_statuses() { + let expected = ExpectedRead { + acceptable_initial_statuses: vec![ExpectedStatus { + status_code: 404, + sub_status_code: Some(1002), + }], + terminal_status: ExpectedStatus { + status_code: 200, + sub_status_code: None, + }, + max_wait_ms: Some(5_000), + }; + + assert!(expected.is_acceptable_initial(404, Some(1002))); + assert!(!expected.is_terminal(404, Some(1002))); + assert!(expected.is_terminal(200, None)); + assert!(!expected.is_acceptable_initial(200, None)); + } +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs new file mode 100644 index 00000000000..3d65a0c5a1c --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::Uuid; +use azure_data_cosmos::{ + clients::ContainerClient, + models::{ContainerProperties, PartitionKeyDefinition}, + options::{OperationOptions, ReadConsistencyStrategy, Region}, + AccountEndpoint, AccountReference, CosmosClient, CosmosRuntime, RoutingStrategy, +}; + +pub type TestResult = Result>; + +pub struct E2eTestFixture { + client: CosmosClient, + database_id: String, + pub container: ContainerClient, +} + +impl E2eTestFixture { + pub async fn run(test: F) -> TestResult + where + F: AsyncFnOnce(&E2eTestFixture) -> TestResult, + { + Self::run_with_partition_key("/pk".into(), test).await + } + + pub async fn run_with_partition_key( + partition_key: PartitionKeyDefinition, + test: F, + ) -> TestResult + where + F: AsyncFnOnce(&E2eTestFixture) -> TestResult, + { + let client = build_client().await?; + Self::run_with_client(client, partition_key, test).await + } + + pub async fn run_with_client( + client: CosmosClient, + partition_key: PartitionKeyDefinition, + test: F, + ) -> TestResult + where + F: AsyncFnOnce(&E2eTestFixture) -> TestResult, + { + let fixture = Self::new(client, partition_key).await?; + let outcome = test(&fixture).await; + let cleanup = fixture.cleanup().await; + outcome?; + cleanup + } + + async fn new(client: CosmosClient, partition_key: PartitionKeyDefinition) -> TestResult { + let database_id = format!("e2e-{}", Uuid::new_v4()); + let container_id = format!("items-{}", Uuid::new_v4()); + client.create_database(&database_id, None).await?; + let database = client.database_client(&database_id); + database + .create_container( + ContainerProperties::new(container_id.clone(), partition_key), + None, + ) + .await?; + let container = database.container_client(&container_id, None).await?; + Ok(Self { + client, + database_id, + container, + }) + } + + async fn cleanup(self) -> TestResult { + self.client + .database_client(&self.database_id) + .delete(None) + .await?; + Ok(()) + } +} + +pub async fn build_client() -> TestResult { + build_client_with_routing(RoutingStrategy::ProximityTo(Region::EAST_US)).await +} + +pub async fn build_client_with_routing( + routing_strategy: RoutingStrategy, +) -> TestResult { + build_client_with_defaults(routing_strategy, None, None).await +} + +pub async fn build_client_with_defaults( + routing_strategy: RoutingStrategy, + runtime_strategy: Option, + client_strategy: Option, +) -> TestResult { + let connection_string = std::env::var("AZURE_COSMOS_CONNECTION_STRING")?; + let endpoint = connection_string_value(&connection_string, "AccountEndpoint")?; + let key = connection_string_value(&connection_string, "AccountKey")?; + let endpoint: AccountEndpoint = endpoint.parse()?; + let mut runtime_builder = CosmosRuntime::builder(); + if let Some(strategy) = runtime_strategy { + let mut options = OperationOptions::default(); + options.read_consistency_strategy = Some(strategy); + runtime_builder = runtime_builder.with_default_operation_options(options); + } + let runtime = runtime_builder.build().await?; + + let mut client_builder = CosmosClient::builder().with_runtime(runtime); + if let Some(strategy) = client_strategy { + let mut options = OperationOptions::default(); + options.read_consistency_strategy = Some(strategy); + client_builder = client_builder.with_default_operation_options(options); + } + Ok(client_builder + .build( + AccountReference::with_authentication_key(endpoint, key), + routing_strategy, + ) + .await?) +} + +fn connection_string_value(connection_string: &str, key: &str) -> TestResult { + connection_string + .split(';') + .filter_map(|part| part.split_once('=')) + .find_map(|(name, value)| name.eq_ignore_ascii_case(key).then_some(value.to_owned())) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("connection string is missing {key}").into()) +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs new file mode 100644 index 00000000000..18dfea95497 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs @@ -0,0 +1,695 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +mod catalog; +mod fixture; + +use azure_core::http::{Etag, StatusCode}; +use azure_data_cosmos::{ + feed::FeedScope, + options::{ + AvailabilityStrategy, ItemReadOptions, ItemWriteOptions, OperationOptions, Precondition, + ReadConsistencyStrategy, Region, + }, + Query, RoutingStrategy, +}; +use futures::{StreamExt, TryStreamExt}; +use serde::{Deserialize, Serialize}; + +use catalog::{assert_diagnostics, assert_status, profile as load_profile, scenario}; +use fixture::{build_client, build_client_with_defaults, E2eTestFixture, TestResult}; + +const IMPLEMENTED_TESTS: &[&str] = &[ + "capability_document_is_versioned", + "bootstrap_primary_endpoint", + "item_lifecycle", + "upsert_creates_then_updates", + "duplicate_create_preserves_original", + "not_found_does_not_cross_partition_keys", + "stale_etag_preserves_successful_update", + "parameterized_query_filters_and_orders", + "invalid_query_is_not_an_empty_feed", + "diagnostics_cover_success_and_error", +]; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +struct Item { + id: String, + pk: String, + value: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + score: Option, +} + +fn item(id: &str, pk: &str, value: i64) -> Item { + Item { + id: id.to_owned(), + pk: pk.to_owned(), + value, + score: None, + } +} + +fn write_options_with_content() -> ItemWriteOptions { + let mut operation = OperationOptions::default(); + operation.content_response_on_write = + Some(azure_data_cosmos::options::ContentResponseOnWrite::Enabled); + ItemWriteOptions::default().with_operation_options(operation) +} + +fn hosted_only() -> bool { + cfg!(any( + test_category = "emulator_inmemory", + test_category = "e2e" + )) +} + +#[test] +fn e2e_scenario_catalog_is_valid() { + catalog::validate_catalog(IMPLEMENTED_TESTS).expect("E2E scenario catalog must be valid"); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CapabilityDocument { + api_version: u32, + emulator_version: String, + protocols: ProtocolCapabilities, + data_plane: Vec, + management_actions: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolCapabilities { + gateway_v1: bool, + gateway_v2: bool, +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn capability_document_is_versioned() -> TestResult { + let test_scenario = scenario("management.capabilities"); + let step = test_scenario.step("readCapabilities"); + let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; + let response = reqwest::Client::new() + .get(url::Url::parse(&management_endpoint)?.join("capabilities")?) + .send() + .await?; + assert_status(step, response.status().as_u16(), None); + let capabilities: CapabilityDocument = serde_json::from_slice(&response.bytes().await?)?; + assert_eq!(capabilities.api_version, 1); + assert!(!capabilities.emulator_version.is_empty()); + assert!(capabilities.protocols.gateway_v1); + assert!(capabilities.data_plane.iter().any(|value| value == "item")); + assert!(capabilities + .management_actions + .iter() + .any(|value| value == "partitionSplit")); + let expects_v2 = std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR").as_deref() == Ok("inmemory-v2"); + assert_eq!(capabilities.protocols.gateway_v2, expects_v2); + Ok(()) +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn bootstrap_primary_endpoint() -> TestResult { + let test_scenario = scenario("bootstrap.primary-success"); + let step = test_scenario.step("buildClient"); + let client = build_client().await?; + assert!(hosted_only()); + let database_id = format!("e2e-bootstrap-{}", azure_core::Uuid::new_v4()); + let response = client.create_database(&database_id, None).await?; + assert_status( + step, + u16::from(response.status().status_code()), + response.status().sub_status().map(|value| value.value()), + ); + assert!(response.status().status_code().is_success()); + response.into_model()?; + client.database_client(&database_id).delete(None).await?; + Ok(()) +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn item_lifecycle() -> TestResult { + let profile = std::env::var("AZURE_COSMOS_E2E_PROFILE") + .unwrap_or_else(|_| "hostedEmulatorSmoke".to_owned()); + run_lifecycle_consistency_matrix(&profile).await +} + +async fn run_lifecycle_consistency_matrix(profile: &str) -> TestResult { + let test_scenario = scenario("item.lifecycle"); + let profile_definition = load_profile(profile); + let account_ids: Vec<_> = profile_definition + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let runtime_ids: Vec<_> = profile_definition + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let client_ids: Vec<_> = profile_definition + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let account_id = selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &account_ids)?; + let runtime_id = selected_axis("AZURE_COSMOS_E2E_RUNTIME", &runtime_ids)?; + let client_id = selected_axis("AZURE_COSMOS_E2E_CLIENT", &client_ids)?; + let executions: Vec<_> = test_scenario + .executions_for_configuration(profile, account_id, runtime_id, client_id) + .collect(); + if executions.is_empty() { + return Err(format!( + "item.lifecycle has no execution for profile='{profile}', account='{account_id}', runtime='{runtime_id}', client='{client_id}'" + ) + .into()); + } + + let runtime_strategy = parse_optional_strategy( + profile_definition + .runtime(runtime_id) + .default_read_consistency_strategy + .as_deref(), + )?; + let client_strategy = parse_optional_strategy( + profile_definition + .client(client_id) + .default_read_consistency_strategy + .as_deref(), + )?; + + for execution in executions { + let read_region = match execution.read_region.as_str() { + "East US" => Region::EAST_US, + "West US" => Region::WEST_US, + region => return Err(format!("unsupported lifecycle read region '{region}'").into()), + }; + let client = build_client_with_defaults( + RoutingStrategy::PreferredRegions(vec![read_region.clone(), Region::EAST_US]), + runtime_strategy, + client_strategy, + ) + .await?; + let definition = test_scenario.fixture("hashV2").partition_key_definition()?; + + E2eTestFixture::run_with_client(client, definition, async |fixture| { + let item_id = format!("lifecycle-{}", execution.id); + let created = fixture + .container + .create_item("A", &item_id, item(&item_id, "A", 1), None) + .await?; + assert_eq!(created.status().status_code(), StatusCode::Created); + let create_token = created.headers().session_token().cloned(); + + let mut operation = OperationOptions::default(); + operation.read_consistency_strategy = parse_optional_strategy( + (execution.read_consistency_strategy != "Inherit") + .then_some(execution.read_consistency_strategy.as_str()), + )?; + operation.availability_strategy = Some(AvailabilityStrategy::Disabled); + let mut read_options = ItemReadOptions::default().with_operation_options(operation); + if execution.session_token == "createResponse" { + read_options = read_options.with_session_token( + create_token + .clone() + .ok_or("create response must carry a session token")?, + ); + } + + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_millis( + execution.expected_read.max_wait_ms.unwrap_or_default(), + ); + let mut observed_statuses = Vec::new(); + loop { + let (status_code, sub_status_code, terminal) = match fixture + .container + .read_item("A", &item_id, Some(read_options.clone())) + .await + { + Ok(read) => { + let status_code = u16::from(read.status().status_code()); + let sub_status_code = + read.status().sub_status().map(|value| value.value()); + for request in read.diagnostics().requests().iter() { + let request_status = u16::from(request.status().status_code()); + let request_sub_status = + request.status().sub_status().map(|value| value.value()); + if execution + .expected_read + .is_acceptable_initial(request_status, request_sub_status) + { + observed_statuses + .push((request_status, request_sub_status.unwrap_or(0))); + } + } + ( + status_code, + sub_status_code, + execution + .expected_read + .is_terminal(status_code, sub_status_code), + ) + } + Err(error) => { + let status_code = u16::from(error.status().status_code()); + let sub_status_code = + error.status().sub_status().map(|value| value.value()); + if let Some(diagnostics) = error.diagnostics() { + for request in diagnostics.requests().iter() { + let request_status = u16::from(request.status().status_code()); + let request_sub_status = + request.status().sub_status().map(|value| value.value()); + if execution + .expected_read + .is_acceptable_initial(request_status, request_sub_status) + { + observed_statuses + .push((request_status, request_sub_status.unwrap_or(0))); + } + } + } + ( + status_code, + sub_status_code, + execution + .expected_read + .is_terminal(status_code, sub_status_code), + ) + } + }; + observed_statuses.push((status_code, sub_status_code.unwrap_or(0))); + if terminal { + break; + } + if !execution + .expected_read + .is_acceptable_initial(status_code, sub_status_code) + { + return Err(format!( + "execution '{}' observed unexpected read status {status_code}/{}; expected transient {:?} or terminal {:?}; observed {observed_statuses:?}", + execution.id, + sub_status_code.unwrap_or(0), + execution.expected_read.acceptable_initial_statuses, + execution.expected_read.terminal_status, + ) + .into()); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "execution '{}' did not reach terminal status {:?} within {} ms; observed {observed_statuses:?}", + execution.id, + execution.expected_read.terminal_status, + execution.expected_read.max_wait_ms.unwrap_or_default(), + ) + .into()); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + let replaced = fixture + .container + .replace_item("A", &item_id, item(&item_id, "A", 2), None) + .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); + let deleted = fixture.container.delete_item("A", &item_id, None).await?; + assert_eq!(deleted.status().status_code(), StatusCode::NoContent); + Ok(()) + }) + .await?; + } + Ok(()) +} + +fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> TestResult<&'a str> { + match std::env::var(environment_variable) { + Ok(selected) => available + .iter() + .copied() + .find(|candidate| *candidate == selected) + .ok_or_else(|| { + format!("{environment_variable}='{selected}' is not one of {available:?}").into() + }), + Err(_) if available.len() == 1 => Ok(available[0]), + Err(_) => Err(format!( + "{environment_variable} is required because this profile defines {available:?}" + ) + .into()), + } +} + +fn parse_optional_strategy(value: Option<&str>) -> TestResult> { + value + .map(str::parse::) + .transpose() + .map_err(Into::into) +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn upsert_creates_then_updates() -> TestResult { + E2eTestFixture::run(async |fixture| { + let test_scenario = scenario("item.upsert-create-update"); + let created = fixture + .container + .upsert_item( + "A", + "upsert-1", + item("upsert-1", "A", 1), + Some(write_options_with_content()), + ) + .await?; + assert_status( + test_scenario.step("upsertCreate"), + u16::from(created.status().status_code()), + None, + ); + assert_diagnostics(test_scenario.step("upsertCreate"), &created.diagnostics()); + let updated = fixture + .container + .upsert_item( + "A", + "upsert-1", + item("upsert-1", "A", 2), + Some(write_options_with_content()), + ) + .await?; + assert_status( + test_scenario.step("upsertUpdate"), + u16::from(updated.status().status_code()), + None, + ); + assert_diagnostics(test_scenario.step("upsertUpdate"), &updated.diagnostics()); + assert_eq!(updated.into_model::()?.value, 2); + Ok(()) + }) + .await +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn duplicate_create_preserves_original() -> TestResult { + let test_scenario = scenario("item.create-conflict"); + assert_eq!( + test_scenario + .fixtures + .iter() + .map(|fixture| fixture.id.as_str()) + .collect::>(), + ["hashV1", "hashV2", "hierarchicalV2"] + ); + + for scenario_fixture in &test_scenario.fixtures { + let partition_key_definition = scenario_fixture.partition_key_definition()?; + let original = scenario_fixture.item("original"); + let duplicate = scenario_fixture.item("duplicate"); + assert!(original.seed); + assert!(!duplicate.seed); + assert_eq!(original.document_id()?, duplicate.document_id()?); + + E2eTestFixture::run_with_partition_key(partition_key_definition, async |fixture| { + fixture + .container + .create_item( + original.partition_key()?, + original.document_id()?, + &original.document, + None, + ) + .await?; + let error = fixture + .container + .create_item( + duplicate.partition_key()?, + duplicate.document_id()?, + &duplicate.document, + None, + ) + .await + .expect_err("duplicate create must fail"); + let step = test_scenario.step("duplicateCreate"); + assert_status( + step, + u16::from(error.status().status_code()), + error.status().sub_status().map(|value| value.value()), + ); + assert_diagnostics( + step, + &error + .diagnostics() + .expect("service error must carry diagnostics"), + ); + let stored: serde_json::Value = fixture + .container + .read_item(original.partition_key()?, original.document_id()?, None) + .await? + .into_model()?; + for (name, expected) in original + .document + .as_object() + .expect("fixture document must be an object") + { + assert_eq!( + stored.get(name), + Some(expected), + "fixture '{}' field '{name}' changed after duplicate create", + scenario_fixture.id + ); + } + Ok(()) + }) + .await?; + } + Ok(()) +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn not_found_does_not_cross_partition_keys() -> TestResult { + E2eTestFixture::run(async |fixture| { + let test_scenario = scenario("item.not-found-wrong-partition-key"); + fixture + .container + .create_item("A", "item-1", item("item-1", "A", 1), None) + .await?; + for (step_id, id, pk) in [ + ("missing", "missing", "A"), + ("wrongPartitionKey", "item-1", "B"), + ] { + let error = fixture + .container + .read_item(pk, id, None) + .await + .expect_err("read must return not found"); + let step = test_scenario.step(step_id); + assert_status( + step, + u16::from(error.status().status_code()), + error.status().sub_status().map(|value| value.value()), + ); + assert_diagnostics( + step, + &error + .diagnostics() + .expect("service error must carry diagnostics"), + ); + } + let read = fixture.container.read_item("A", "item-1", None).await?; + assert_eq!(read.into_model::()?.value, 1); + Ok(()) + }) + .await +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn stale_etag_preserves_successful_update() -> TestResult { + E2eTestFixture::run(async |fixture| { + let test_scenario = scenario("item.optimistic-concurrency"); + let created = fixture + .container + .create_item("A", "etag-1", item("etag-1", "A", 1), None) + .await?; + let initial_etag = created + .headers() + .etag() + .expect("create must return an ETag") + .clone(); + let current_options = ItemWriteOptions::default() + .with_precondition(Precondition::IfMatch(initial_etag.clone())); + let replaced = fixture + .container + .replace_item("A", "etag-1", item("etag-1", "A", 2), Some(current_options)) + .await?; + assert_status( + test_scenario.step("replaceCurrent"), + u16::from(replaced.status().status_code()), + None, + ); + let stale_options = ItemWriteOptions::default() + .with_precondition(Precondition::IfMatch(Etag::from(initial_etag.to_string()))); + let error = fixture + .container + .replace_item("A", "etag-1", item("etag-1", "A", 3), Some(stale_options)) + .await + .expect_err("stale ETag must fail"); + let step = test_scenario.step("replaceStale"); + assert_status( + step, + u16::from(error.status().status_code()), + error.status().sub_status().map(|value| value.value()), + ); + assert_diagnostics( + step, + &error + .diagnostics() + .expect("service error must carry diagnostics"), + ); + assert_eq!( + fixture + .container + .read_item("A", "etag-1", None) + .await? + .into_model::()? + .value, + 2 + ); + Ok(()) + }) + .await +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn parameterized_query_filters_and_orders() -> TestResult { + E2eTestFixture::run(async |fixture| { + let test_scenario = scenario("query.parameterized-filter"); + for score in 1..=3 { + let id = format!("item-{score}"); + let mut value = item(&id, "A", score); + value.score = Some(score); + fixture.container.create_item("A", &id, value, None).await?; + } + let query = Query::from( + "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score ASC", + ) + .with_parameter("@pk", "A")? + .with_parameter("@min", 2)?; + let mut results = fixture + .container + .query_items::(query, FeedScope::partition("A"), None) + .await?; + let items: Vec = results.by_ref().try_collect().await?; + assert_eq!( + items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["item-2", "item-3"] + ); + assert_eq!(test_scenario.step("query").expected.status, 200); + Ok(()) + }) + .await +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn invalid_query_is_not_an_empty_feed() -> TestResult { + E2eTestFixture::run(async |fixture| { + let test_scenario = scenario("query.invalid-syntax"); + let result = fixture + .container + .query_items::("SELECT FROM", FeedScope::partition("A"), None) + .await; + let error = match result { + Err(error) => error, + Ok(mut stream) => stream + .next() + .await + .expect("invalid query must produce an error") + .expect_err("invalid query must not produce a page"), + }; + assert_status( + test_scenario.step("invalidQuery"), + u16::from(error.status().status_code()), + error.status().sub_status().map(|value| value.value()), + ); + Ok(()) + }) + .await +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn diagnostics_cover_success_and_error() -> TestResult { + E2eTestFixture::run(async |fixture| { + let test_scenario = scenario("diagnostics.success-and-error"); + fixture + .container + .create_item("A", "item-1", item("item-1", "A", 1), None) + .await?; + let success = fixture.container.read_item("A", "item-1", None).await?; + let success_step = test_scenario.step("successfulRead"); + assert_status( + success_step, + u16::from(success.status().status_code()), + None, + ); + assert_diagnostics(success_step, &success.diagnostics()); + let error = fixture + .container + .read_item("A", "missing", None) + .await + .expect_err("missing read must fail"); + let error_step = test_scenario.step("missingRead"); + assert_status( + error_step, + u16::from(error.status().status_code()), + error.status().sub_status().map(|value| value.value()), + ); + assert_diagnostics( + error_step, + &error + .diagnostics() + .expect("service error must carry diagnostics"), + ); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_tests.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_tests.rs new file mode 100644 index 00000000000..0e76dc81cc1 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_tests.rs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#![allow(clippy::large_futures)] + +mod e2e_test_cases; diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs b/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs index a6b7ac5a549..60823dfc131 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs @@ -40,12 +40,15 @@ pub enum ReadConsistencyStrategy { /// read-your-writes within any single session. Session, - /// Returns the latest committed version of the requested item across replicas. + /// Returns the latest committed version available across replicas in the + /// selected read region. /// /// On accounts whose default consistency is Session, ConsistentPrefix, or Eventual, /// this strategy upgrades the read to a quorum read (1:2 client-to-backend /// amplification) without weakening any other operation. On Strong / BoundedStaleness - /// accounts it behaves like the account default. + /// accounts it behaves like the account default. This is a region-local + /// quorum read, not a cross-region replication barrier: a newly written + /// item can be temporarily absent from a lagging secondary region. LatestCommitted, /// Reads the latest version across all regions. diff --git a/sdk/cosmos/azure_data_cosmos_emulator/README.md b/sdk/cosmos/azure_data_cosmos_emulator/README.md index c38033f28c6..53155c5273e 100644 --- a/sdk/cosmos/azure_data_cosmos_emulator/README.md +++ b/sdk/cosmos/azure_data_cosmos_emulator/README.md @@ -22,6 +22,9 @@ and the architecture decision records under `docs/adr/`. - A **management REST API** for emulator-only control-plane actions with no Cosmos gateway equivalent: partition split/merge (as long-running operations), per-partition-failover toggling, and replication pause/resume. +- A versioned **capability document** at `GET /capabilities` so external SDK + runners can fail closed when a required protocol or management action is not + available. ## Quick start diff --git a/sdk/cosmos/azure_data_cosmos_emulator/src/management.rs b/sdk/cosmos/azure_data_cosmos_emulator/src/management.rs index 2b2d467c5f3..1730513359b 100644 --- a/sdk/cosmos/azure_data_cosmos_emulator/src/management.rs +++ b/sdk/cosmos/azure_data_cosmos_emulator/src/management.rs @@ -79,6 +79,7 @@ fn router( }; Router::new() .route("/health", get(health)) + .route("/capabilities", get(capabilities)) .route("/account", get(account)) .route( "/databases/{database}/containers/{container}/partitions/{partition_id}/split", @@ -120,6 +121,64 @@ async fn health(State(state): State) -> Json })) } +const CAPABILITIES_API_VERSION: u32 = 1; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CapabilitiesResponse { + api_version: u32, + emulator_version: &'static str, + protocols: ProtocolCapabilities, + data_plane: &'static [&'static str], + management_actions: &'static [&'static str], + limitations: &'static [&'static str], +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolCapabilities { + gateway_v1: bool, + gateway_v2: bool, +} + +/// Returns the versioned capabilities document consumed by external SDK E2E +/// test runners. Capabilities describe what the current host can exercise; +/// they do not claim production-service fidelity beyond the listed limits. +async fn capabilities(State(state): State) -> Json { + Json(CapabilitiesResponse { + api_version: CAPABILITIES_API_VERSION, + emulator_version: env!("CARGO_PKG_VERSION"), + protocols: ProtocolCapabilities { + gateway_v1: true, + gateway_v2: state + .bindings + .iter() + .any(|binding| binding.gateway20_url.is_some()), + }, + data_plane: &[ + "database", + "container", + "offer", + "item", + "query", + "changeFeed", + "transactionalBatch", + "patch", + ], + management_actions: &[ + "partitionSplit", + "partitionMerge", + "perPartitionFailover", + "replicationPauseResume", + ], + limitations: &[ + "authenticationNotEnforced", + "loopbackOnly", + "volatileStorage", + ], + }) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct AccountResponse { @@ -1350,6 +1409,36 @@ mod tests { server.abort(); } + #[tokio::test] + async fn capabilities_report_configured_protocols() { + let gateway_url = Url::parse("http://127.0.0.1:18081/").unwrap(); + let gateway20_url = Url::parse("http://127.0.0.1:18082/").unwrap(); + let config = + VirtualAccountConfig::new(vec![VirtualRegion::new("East US", gateway_url.clone()) + .with_gateway_v2_url(gateway20_url.clone())]) + .unwrap(); + let emulator = Arc::new(InMemoryEmulatorHttpClient::new(config)); + let state = ManagementState { + emulator, + account_id: "test-account".into(), + bindings: vec![GatewayBinding { + region_name: "East US".to_owned(), + gateway_url, + gateway20_url: Some(gateway20_url), + }] + .into(), + metrics: Arc::new(HostMetrics::default()), + operations: Arc::new(OperationRegistry::default()), + }; + + let response = capabilities(State(state)).await.0; + assert_eq!(response.api_version, CAPABILITIES_API_VERSION); + assert!(response.protocols.gateway_v1); + assert!(response.protocols.gateway_v2); + assert!(response.data_plane.contains(&"item")); + assert!(response.management_actions.contains(&"partitionSplit")); + } + #[tokio::test] async fn replication_pause_and_resume_round_trip_through_management_api() { let east_url = Url::parse("http://127.0.0.1:18081/").unwrap(); diff --git a/sdk/cosmos/ci.yml b/sdk/cosmos/ci.yml index 100262ad3a2..ae821cf82b8 100644 --- a/sdk/cosmos/ci.yml +++ b/sdk/cosmos/ci.yml @@ -120,6 +120,20 @@ extends: Path: sdk/cosmos/inmemory-emulator-matrix.json Selection: all GenerateVMJobs: true + # The full consistency × read-consistency-strategy lifecycle matrix is + # intentionally scheduled rather than blocking every PR. Jobs select + # account/runtime/client cells from the two focused profiles. Non-Strong + # account definitions inject a deterministic replication delay so + # regional 404/session-recovery behavior is actually exercised. + - ${{ if or(eq(variables['Build.Reason'], 'Schedule'), endsWith(variables['Build.DefinitionName'], '- weekly')) }}: + - Name: Cosmos_e2e_consistency + Path: sdk/cosmos/e2e-consistency-matrix.json + Selection: all + GenerateVMJobs: true + - Name: Cosmos_e2e_read_consistency_overrides + Path: sdk/cosmos/e2e-read-consistency-override-matrix.json + Selection: all + GenerateVMJobs: true LiveTestMatrixConfigs: - Name: Cosmos_live_test Path: sdk/cosmos/live-platform-matrix.json diff --git a/sdk/cosmos/e2e-consistency-matrix.json b/sdk/cosmos/e2e-consistency-matrix.json new file mode 100644 index 00000000000..75a52545c56 --- /dev/null +++ b/sdk/cosmos/e2e-consistency-matrix.json @@ -0,0 +1,25 @@ +{ + "displayNames": { + "inmemory-v1": "gateway_v1", + "inmemory-v2": "gateway_v2", + "strong": "strong_2_regions", + "boundedStaleness": "bounded_staleness_2_regions_delayed", + "session": "session_2_regions_delayed", + "consistentPrefix": "consistent_prefix_2_regions_delayed", + "eventual": "eventual_2_regions_delayed" + }, + "matrix": { + "Agent": { + "ubuntu": { + "OSVmImage": "env:LINUXVMIMAGE", + "Pool": "env:LINUXPOOL" + } + }, + "RustToolchainName": ["stable"], + "AZURE_COSMOS_EMULATOR_FLAVOR": ["inmemory-v1", "inmemory-v2"], + "AZURE_COSMOS_E2E_PROFILE": ["lifecycleConsistencyMatrix"], + "AZURE_COSMOS_E2E_ACCOUNT": ["strong", "boundedStaleness", "session", "consistentPrefix", "eventual"], + "AZURE_COSMOS_E2E_RUNTIME": ["unset"], + "AZURE_COSMOS_E2E_CLIENT": ["unset"] + } +} \ No newline at end of file diff --git a/sdk/cosmos/e2e-read-consistency-override-matrix.json b/sdk/cosmos/e2e-read-consistency-override-matrix.json new file mode 100644 index 00000000000..90d19fe80fe --- /dev/null +++ b/sdk/cosmos/e2e-read-consistency-override-matrix.json @@ -0,0 +1,24 @@ +{ + "displayNames": { + "inmemory-v1": "gateway_v1", + "inmemory-v2": "gateway_v2", + "unset": "unset", + "eventual": "eventual", + "session": "session", + "latestCommitted": "latest_committed" + }, + "matrix": { + "Agent": { + "ubuntu": { + "OSVmImage": "env:LINUXVMIMAGE", + "Pool": "env:LINUXPOOL" + } + }, + "RustToolchainName": ["stable"], + "AZURE_COSMOS_EMULATOR_FLAVOR": ["inmemory-v1", "inmemory-v2"], + "AZURE_COSMOS_E2E_PROFILE": ["readConsistencyOverrideMatrix"], + "AZURE_COSMOS_E2E_ACCOUNT": ["session"], + "AZURE_COSMOS_E2E_RUNTIME": ["unset", "eventual", "session"], + "AZURE_COSMOS_E2E_CLIENT": ["unset", "latestCommitted"] + } +} \ No newline at end of file diff --git a/sdk/cosmos/e2e_tests/README.md b/sdk/cosmos/e2e_tests/README.md new file mode 100644 index 00000000000..a5768d760d3 --- /dev/null +++ b/sdk/cosmos/e2e_tests/README.md @@ -0,0 +1,120 @@ +# Cosmos SDK E2E test scenarios + +This directory contains language-neutral E2E test scenarios for Cosmos DB +SDKs. A scenario describes observable SDK behavior; it does not prescribe a +language-specific API shape or expose driver internals. + +## Layout + +- `schema/` contains the JSON Schemas for scenarios and profiles. +- `vocabulary/` contains controlled identifiers shared by SDK runners. +- `profiles/` contains reusable account, runtime, and client configurations. +- `scenarios/` contains one JSON document per semantic scenario. +- `implementations/` maps scenarios to SDK-specific test implementations. + +Scenario IDs are permanent. If an established scenario changes meaning, add a +new scenario and deprecate the old one rather than reusing its ID. + +## Precedents and implementations + +The `precedents` array records prior evidence for the expected behavior. A +precedent can point to a service specification or an existing Rust, Java, +.NET, or Python test. It does not mean that the scenario is implemented only +for that SDK, and it is not the implementation registry. + +SDK implementations are tracked separately under `implementations/`. The +initial `rust.json` mapping links every scenario ID to an executable Rust E2E +test. Catalog validation fails if an active Rust mapping names a missing test, +if a scenario has no Rust mapping, or if a scenario is mapped more than once. +Future SDKs add their own implementation map without changing the +language-neutral scenario or its precedents. + +## Fixture variants + +Every scenario declares a `fixtures` array. Each fixture is one cohesive test +setup containing: + +- a container partition-key definition, including paths, kind, and version; +- named item definitions; +- each item's partition-key values and JSON document; +- whether the item is seeded before the scenario steps run. + +The same semantic steps run for every fixture unless an SDK implementation +documents a backend limitation. This makes partitioning variants explicit +without duplicating the scenario. For example, the duplicate-create scenario +runs against Hash V1, Hash V2, and hierarchical MultiHash V2 containers. + +Steps can reference a named fixture item through `itemRef`. Keeping the +partition-key values beside the corresponding document prevents setup data +from drifting away from its container definition. + +## Account profiles and execution cases + +Each reusable profile contains three arrays: `accounts`, `runtimes`, and +`clients`. Its configuration space is their Cartesian product. Every +definition has a stable ID, and an execution selects one account/runtime/client +cell by ID. Keeping several definitions in one focused profile avoids creating +one profile file for every combination. + +Runtime and client definitions can specify +`defaultReadConsistencyStrategy`. The execution's +`readConsistencyStrategy` is the operation-level value; `Inherit` leaves it +unset. This directly tests the precedence order operation > client > runtime > +account default. In particular, an explicit operation-level `Default` clears a +lower-layer non-default strategy and restores account-default read behavior. + +The item lifecycle scenario uses two bounded profiles rather than one giant +Cartesian product: + +- `lifecycleConsistencyMatrix`: five account definitions × one runtime × one + client × five operation strategies (25 executions per gateway); +- `readConsistencyOverrideMatrix`: one Session account × three runtime + definitions × two client definitions × three operation values (`Inherit`, + `Default`, and `Eventual`) (18 executions per gateway). + +Non-Strong two-region account definitions inject a deterministic replication +delay. Each execution declares `acceptableInitialStatuses`, a `terminalStatus`, +and, when transient statuses are accepted, `maxWaitMs`. The runner accepts an +immediate terminal result. Otherwise, it retries only public results matching +an acceptable status/substatus pair until it reaches the terminal pair or the +time cap. An omitted substatus matches any substatus; a missing response +substatus is normalized to zero when an explicit substatus is expected. + +SDK-internal retries can hide an acceptable transient status from the public +result. The runner includes matching internal diagnostic attempts in failure +evidence but does not require a transient attempt. This keeps the same scenario +valid for deterministic emulator delay and nondeterministic live replication. + +`LatestCommitted` means the latest committed value available in the selected +read region. It is not a cross-region replication barrier, so a delayed +secondary can return a temporary plain 404. Only a completed Strong-account +write guarantees that an eligible secondary can immediately return the item. + +The scheduled matrices in `e2e-consistency-matrix.json` and +`e2e-read-consistency-override-matrix.json` execute every lifecycle case +through Gateway V1 and Gateway V2. Profile-driven jobs use the isolated `e2e` +test category so they do not rerun unrelated emulator suites. + +## Assertions + +Scenarios standardize outcomes, HTTP status and substatus, data side effects, +and selected structured diagnostics. Error text, serialized diagnostics, +opaque identifiers, exact request charge, and incidental timing are not stable +E2E assertions. + +Backend applicability is explicit: + +- `required`: the scenario must execute and pass. +- `supported`: expected to work but not required in every pipeline. +- `simulated`: useful deterministic emulator behavior, not a service-fidelity claim. +- `notApplicable`: intentionally unavailable, with a reason. + +An unavailable required capability is a test configuration failure, never a +silent skip. + +## Initial execution + +The Rust implementation is under +`azure_data_cosmos/tests/e2e_test_cases/`. Product operations use only the +public `azure_data_cosmos` surface. Emulator-only orchestration uses the +external management endpoint. diff --git a/sdk/cosmos/e2e_tests/implementations/rust.json b/sdk/cosmos/e2e_tests/implementations/rust.json new file mode 100644 index 00000000000..655c3cbc34a --- /dev/null +++ b/sdk/cosmos/e2e_tests/implementations/rust.json @@ -0,0 +1,17 @@ +{ + "specVersion": "1.0", + "sdk": "rust", + "testTarget": "e2e_tests", + "scenarios": [ + { "id": "management.capabilities", "test": "capability_document_is_versioned", "status": "active" }, + { "id": "bootstrap.primary-success", "test": "bootstrap_primary_endpoint", "status": "active" }, + { "id": "item.lifecycle", "test": "item_lifecycle", "status": "active" }, + { "id": "item.upsert-create-update", "test": "upsert_creates_then_updates", "status": "active" }, + { "id": "item.create-conflict", "test": "duplicate_create_preserves_original", "status": "active" }, + { "id": "item.not-found-wrong-partition-key", "test": "not_found_does_not_cross_partition_keys", "status": "active" }, + { "id": "item.optimistic-concurrency", "test": "stale_etag_preserves_successful_update", "status": "active" }, + { "id": "query.parameterized-filter", "test": "parameterized_query_filters_and_orders", "status": "active" }, + { "id": "query.invalid-syntax", "test": "invalid_query_is_not_an_empty_feed", "status": "active" }, + { "id": "diagnostics.success-and-error", "test": "diagnostics_cover_success_and_error", "status": "active" } + ] +} \ No newline at end of file diff --git a/sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json b/sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json new file mode 100644 index 00000000000..ce566dbdb06 --- /dev/null +++ b/sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json @@ -0,0 +1,30 @@ +{ + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "hostedEmulatorSmoke", + "accounts": [{ + "id": "sessionSingleRegion", + "writeMode": "single", + "consistency": "session", + "regions": [ + { + "name": "East US" + } + ], + "replication": { + "minDelayMs": 0, + "maxDelayMs": 0 + }, + "perPartitionFailover": false + }], + "runtimes": [{ + "id": "sdkDefault", + "gatewayV2": "backendDefault", + "ppcb": "sdkDefault" + }], + "clients": [{ + "id": "sdkDefault", + "binaryEncoding": "sdkDefault", + "routing": "proximity" + }] +} diff --git a/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json b/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json new file mode 100644 index 00000000000..31ebf180429 --- /dev/null +++ b/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json @@ -0,0 +1,30 @@ +{ + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "legacyGatewayV1", + "accounts": [{ + "id": "sessionSingleRegion", + "writeMode": "single", + "consistency": "session", + "regions": [ + { + "name": "East US" + } + ], + "replication": { + "minDelayMs": 0, + "maxDelayMs": 0 + }, + "perPartitionFailover": false + }], + "runtimes": [{ + "id": "gatewayV1PpcbDisabled", + "gatewayV2": "disabled", + "ppcb": "disabled" + }], + "clients": [{ + "id": "textAccountOrder", + "binaryEncoding": "disabled", + "routing": "accountOrder" + }] +} diff --git a/sdk/cosmos/e2e_tests/profiles/lifecycleConsistencyMatrix.json b/sdk/cosmos/e2e_tests/profiles/lifecycleConsistencyMatrix.json new file mode 100644 index 00000000000..d876167de1c --- /dev/null +++ b/sdk/cosmos/e2e_tests/profiles/lifecycleConsistencyMatrix.json @@ -0,0 +1,49 @@ +{ + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "lifecycleConsistencyMatrix", + "accounts": [ + { + "id": "strong", + "writeMode": "single", + "consistency": "strong", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 0, "maxDelayMs": 0 }, + "perPartitionFailover": false + }, + { + "id": "boundedStaleness", + "writeMode": "single", + "consistency": "boundedStaleness", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + }, + { + "id": "session", + "writeMode": "single", + "consistency": "session", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + }, + { + "id": "consistentPrefix", + "writeMode": "single", + "consistency": "consistentPrefix", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + }, + { + "id": "eventual", + "writeMode": "single", + "consistency": "eventual", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + } + ], + "runtimes": [{ "id": "unset", "gatewayV2": "backendDefault", "ppcb": "disabled" }], + "clients": [{ "id": "unset", "binaryEncoding": "sdkDefault", "routing": "preferredRegions" }] +} diff --git a/sdk/cosmos/e2e_tests/profiles/readConsistencyOverrideMatrix.json b/sdk/cosmos/e2e_tests/profiles/readConsistencyOverrideMatrix.json new file mode 100644 index 00000000000..0237a6c6e04 --- /dev/null +++ b/sdk/cosmos/e2e_tests/profiles/readConsistencyOverrideMatrix.json @@ -0,0 +1,22 @@ +{ + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "readConsistencyOverrideMatrix", + "accounts": [{ + "id": "session", + "writeMode": "single", + "consistency": "session", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + }], + "runtimes": [ + { "id": "unset", "gatewayV2": "backendDefault", "ppcb": "disabled" }, + { "id": "eventual", "gatewayV2": "backendDefault", "ppcb": "disabled", "defaultReadConsistencyStrategy": "Eventual" }, + { "id": "session", "gatewayV2": "backendDefault", "ppcb": "disabled", "defaultReadConsistencyStrategy": "Session" } + ], + "clients": [ + { "id": "unset", "binaryEncoding": "sdkDefault", "routing": "preferredRegions" }, + { "id": "latestCommitted", "binaryEncoding": "sdkDefault", "routing": "preferredRegions", "defaultReadConsistencyStrategy": "LatestCommitted" } + ] +} diff --git a/sdk/cosmos/e2e_tests/profiles/targetDefault.json b/sdk/cosmos/e2e_tests/profiles/targetDefault.json new file mode 100644 index 00000000000..2d3d1f3ca33 --- /dev/null +++ b/sdk/cosmos/e2e_tests/profiles/targetDefault.json @@ -0,0 +1,33 @@ +{ + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "targetDefault", + "accounts": [{ + "id": "sessionTwoRegionDelayed", + "writeMode": "single", + "consistency": "session", + "regions": [ + { + "name": "East US" + }, + { + "name": "West US" + } + ], + "replication": { + "minDelayMs": 500, + "maxDelayMs": 500 + }, + "perPartitionFailover": true + }], + "runtimes": [{ + "id": "gatewayV2Ppcb", + "gatewayV2": "enabled", + "ppcb": "enabled" + }], + "clients": [{ + "id": "binaryProximity", + "binaryEncoding": "enabled", + "routing": "proximity" + }] +} diff --git a/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json new file mode 100644 index 00000000000..fee14a80ed9 --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json @@ -0,0 +1,71 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "bootstrap.primary-success", + "title": "A reachable primary endpoint initializes the client", + "requirement": "Building a client with a reachable account endpoint succeeds and permits a first data operation.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "rust", + "path": "sdk/cosmos/azure_data_cosmos/tests/emulator_tests/cosmos_items.rs", + "test": "item_crud" + } + ], + "profile": "hostedEmulatorSmoke", + "tags": [ + "prSmoke", + "bootstrap" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + }, + "fixtures": [ + { + "id": "default", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [] + } + ], + "steps": [ + { + "id": "buildClient", + "action": { + "kind": "sdkOperation", + "operation": "buildClient" + }, + "expected": { + "outcome": "success", + "status": 201, + "state": { + "firstOperationSucceeds": true + } + } + } + ], + "cleanup": { + "deleteDatabase": true + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json new file mode 100644 index 00000000000..2634c0c71ff --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json @@ -0,0 +1,148 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "diagnostics.success-and-error", + "title": "Success and error paths expose critical diagnostics", + "requirement": "Public diagnostics identify the logical operation, effective status, activity, attempts, and contacted region on successful and failed operations.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "java", + "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosDiagnosticsE2ETest.java", + "test": "onlyCustomDiagnosticsHandler" + } + ], + "profile": "hostedEmulatorSmoke", + "tags": [ + "prSmoke", + "diagnostics" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + }, + "fixtures": [ + { + "id": "hashV2", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [ + { + "id": "existing", + "seed": true, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "item-1", + "pk": "A", + "value": 1 + } + } + ] + } + ], + "steps": [ + { + "id": "successfulRead", + "action": { + "kind": "sdkOperation", + "operation": "readItem", + "input": { + "id": "item-1", + "pk": "A" + } + }, + "expected": { + "outcome": "success", + "status": 200 + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "read_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 200 + }, + "requestCount": { + "comparator": "atLeast", + "value": 1 + }, + "regionsContacted": { + "comparator": "contains", + "value": [ + "eastus" + ] + } + } + }, + { + "id": "missingRead", + "action": { + "kind": "sdkOperation", + "operation": "readItem", + "input": { + "id": "missing", + "pk": "A" + } + }, + "expected": { + "outcome": "error", + "status": 404, + "subStatus": 0, + "errorCategory": "notFound" + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "read_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 404 + }, + "requestCount": { + "comparator": "atLeast", + "value": 1 + }, + "regionsContacted": { + "comparator": "contains", + "value": [ + "eastus" + ] + } + } + } + ], + "cleanup": { + "deleteDatabase": true + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json new file mode 100644 index 00000000000..d07f03f1bde --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json @@ -0,0 +1,201 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.create-conflict", + "title": "Creating a duplicate identity returns conflict", + "requirement": "A duplicate create returns 409 and leaves the original document unchanged.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "python", + "path": "sdk/cosmos/azure-cosmos/tests/test_crud.py", + "test": "duplicate document create" + } + ], + "profile": "hostedEmulatorSmoke", + "tags": [ + "prSmoke", + "item", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + }, + "fixtures": [ + { + "id": "hashV1", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 1 + } + }, + "items": [ + { + "id": "original", + "seed": true, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "duplicate-1", + "pk": "A", + "value": 1 + } + }, + { + "id": "duplicate", + "seed": false, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "duplicate-1", + "pk": "A", + "value": 2 + } + } + ] + }, + { + "id": "hashV2", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [ + { + "id": "original", + "seed": true, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "duplicate-1", + "pk": "A", + "value": 1 + } + }, + { + "id": "duplicate", + "seed": false, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "duplicate-1", + "pk": "A", + "value": 2 + } + } + ] + }, + { + "id": "hierarchicalV2", + "container": { + "partitionKey": { + "paths": [ + "/tenant", + "/user" + ], + "kind": "MultiHash", + "version": 2 + } + }, + "items": [ + { + "id": "original", + "seed": true, + "partitionKeyValues": [ + "tenant-a", + "user-1" + ], + "document": { + "id": "duplicate-1", + "tenant": "tenant-a", + "user": "user-1", + "value": 1 + } + }, + { + "id": "duplicate", + "seed": false, + "partitionKeyValues": [ + "tenant-a", + "user-1" + ], + "document": { + "id": "duplicate-1", + "tenant": "tenant-a", + "user": "user-1", + "value": 2 + } + } + ] + } + ], + "steps": [ + { + "id": "duplicateCreate", + "action": { + "kind": "sdkOperation", + "operation": "createItem", + "input": { + "itemRef": "duplicate" + } + }, + "expected": { + "outcome": "error", + "status": 409, + "subStatus": 0, + "errorCategory": "conflict", + "state": { + "itemRef": "original", + "unchanged": true + } + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "create_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 409 + }, + "requestCount": { + "comparator": "atLeast", + "value": 1 + } + } + } + ], + "cleanup": { + "deleteDatabase": true + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json new file mode 100644 index 00000000000..0c8c60d21d0 --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json @@ -0,0 +1,600 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.lifecycle", + "title": "An item completes its CRUD lifecycle", + "requirement": "Create, read, replace, and delete preserve the addressed item identity and expose stable statuses.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "java", + "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemTest.java", + "test": "createItem/readItem/replaceItem/deleteItem" + } + ], + "tags": [ + "prSmoke", + "item" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + }, + "fixtures": [ + { + "id": "hashV2", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [] + } + ], + "executions": [ + { + "id": "hostedSmokeDefault", + "profile": "hostedEmulatorSmoke", + "account": "sessionSingleRegion", + "runtime": "sdkDefault", + "client": "sdkDefault", + "readConsistencyStrategy": "Default", + "readRegion": "East US", + "sessionToken": "automatic", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 200 } + } + }, + { + "id": "strongDefault", + "profile": "lifecycleConsistencyMatrix", + "account": "strong", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Default", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 200 } + } + }, + { + "id": "strongEventual", + "profile": "lifecycleConsistencyMatrix", + "account": "strong", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Eventual", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 200 } + } + }, + { + "id": "strongSession", + "profile": "lifecycleConsistencyMatrix", + "account": "strong", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Session", + "readRegion": "West US", + "sessionToken": "createResponse", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 200 } + } + }, + { + "id": "strongLatestCommitted", + "profile": "lifecycleConsistencyMatrix", + "account": "strong", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "LatestCommitted", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 200 } + } + }, + { + "id": "strongGlobalStrong", + "profile": "lifecycleConsistencyMatrix", + "account": "strong", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "GlobalStrong", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 200 } + } + }, + { + "id": "boundedDefault", + "profile": "lifecycleConsistencyMatrix", + "account": "boundedStaleness", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Default", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "boundedEventual", + "profile": "lifecycleConsistencyMatrix", + "account": "boundedStaleness", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Eventual", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "boundedSession", + "profile": "lifecycleConsistencyMatrix", + "account": "boundedStaleness", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Session", + "readRegion": "West US", + "sessionToken": "createResponse", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "boundedLatestCommitted", + "profile": "lifecycleConsistencyMatrix", + "account": "boundedStaleness", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "LatestCommitted", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "boundedGlobalStrong", + "profile": "lifecycleConsistencyMatrix", + "account": "boundedStaleness", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "GlobalStrong", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 400 } + } + }, + { + "id": "sessionDefault", + "profile": "lifecycleConsistencyMatrix", + "account": "session", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Default", + "readRegion": "West US", + "sessionToken": "automatic", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "sessionEventual", + "profile": "lifecycleConsistencyMatrix", + "account": "session", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Eventual", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "sessionSession", + "profile": "lifecycleConsistencyMatrix", + "account": "session", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Session", + "readRegion": "West US", + "sessionToken": "createResponse", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "sessionLatestCommitted", + "profile": "lifecycleConsistencyMatrix", + "account": "session", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "LatestCommitted", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "sessionGlobalStrong", + "profile": "lifecycleConsistencyMatrix", + "account": "session", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "GlobalStrong", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 400 } + } + }, + { + "id": "prefixDefault", + "profile": "lifecycleConsistencyMatrix", + "account": "consistentPrefix", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Default", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "prefixEventual", + "profile": "lifecycleConsistencyMatrix", + "account": "consistentPrefix", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Eventual", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "prefixSession", + "profile": "lifecycleConsistencyMatrix", + "account": "consistentPrefix", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Session", + "readRegion": "West US", + "sessionToken": "createResponse", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "prefixLatestCommitted", + "profile": "lifecycleConsistencyMatrix", + "account": "consistentPrefix", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "LatestCommitted", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "prefixGlobalStrong", + "profile": "lifecycleConsistencyMatrix", + "account": "consistentPrefix", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "GlobalStrong", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 400 } + } + }, + { + "id": "eventualDefault", + "profile": "lifecycleConsistencyMatrix", + "account": "eventual", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Default", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "eventualEventual", + "profile": "lifecycleConsistencyMatrix", + "account": "eventual", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Eventual", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "eventualSession", + "profile": "lifecycleConsistencyMatrix", + "account": "eventual", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "Session", + "readRegion": "West US", + "sessionToken": "createResponse", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "eventualLatestCommitted", + "profile": "lifecycleConsistencyMatrix", + "account": "eventual", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "LatestCommitted", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], + "terminalStatus": { "statusCode": 200 }, + "maxWaitMs": 5000 + } + }, + { + "id": "eventualGlobalStrong", + "profile": "lifecycleConsistencyMatrix", + "account": "eventual", + "runtime": "unset", + "client": "unset", + "readConsistencyStrategy": "GlobalStrong", + "readRegion": "West US", + "sessionToken": "none", + "expectedRead": { + "acceptableInitialStatuses": [], + "terminalStatus": { "statusCode": 400 } + } + }, + { "id": "overrideUnsetUnsetInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "unset", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "automatic", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideEventualUnsetInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "unset", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideSessionUnsetInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "unset", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "automatic", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideUnsetLatestInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "latestCommitted", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideEventualLatestInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "latestCommitted", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideSessionLatestInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "latestCommitted", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + + { "id": "overrideUnsetUnsetDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "unset", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideEventualUnsetDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "unset", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideSessionUnsetDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "unset", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideUnsetLatestDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "latestCommitted", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideEventualLatestDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "latestCommitted", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideSessionLatestDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "latestCommitted", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + + { "id": "overrideUnsetUnsetEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "unset", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideEventualUnsetEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "unset", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideSessionUnsetEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "unset", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideUnsetLatestEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "latestCommitted", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideEventualLatestEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "latestCommitted", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, + { "id": "overrideSessionLatestEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "latestCommitted", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } } + ], + "steps": [ + { + "id": "create", + "action": { + "kind": "sdkOperation", + "operation": "createItem", + "input": { + "id": "item-1", + "pk": "A", + "value": 1 + } + }, + "expected": { + "outcome": "success", + "status": 201, + "state": { + "value": 1 + } + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "create_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 201 + }, + "requestCount": { + "comparator": "atLeast", + "value": 1 + } + } + }, + { + "id": "read", + "action": { + "kind": "sdkOperation", + "operation": "readItem", + "input": { + "id": "item-1", + "pk": "A" + } + }, + "expected": { + "outcome": "success", + "status": 200, + "state": { + "value": 1 + } + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "read_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 200 + }, + "requestCount": { + "comparator": "atLeast", + "value": 1 + } + } + }, + { + "id": "replace", + "action": { + "kind": "sdkOperation", + "operation": "replaceItem", + "input": { + "id": "item-1", + "pk": "A", + "value": 2 + } + }, + "expected": { + "outcome": "success", + "status": 200, + "state": { + "value": 2 + } + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "replace_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 200 + }, + "requestCount": { + "comparator": "atLeast", + "value": 1 + } + } + }, + { + "id": "delete", + "action": { + "kind": "sdkOperation", + "operation": "deleteItem", + "input": { + "id": "item-1", + "pk": "A" + } + }, + "expected": { + "outcome": "success", + "status": 204, + "state": { + "exists": false + } + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "delete_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 204 + }, + "requestCount": { + "comparator": "atLeast", + "value": 1 + } + } + } + ], + "cleanup": { + "deleteDatabase": true + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json new file mode 100644 index 00000000000..e220ce5e71a --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json @@ -0,0 +1,134 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.not-found-wrong-partition-key", + "title": "Missing identity and wrong partition key return not found", + "requirement": "Point reads do not leak an item across partition-key identities and return 404 without mutation.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "rust", + "path": "sdk/cosmos/azure_data_cosmos_driver/tests/in_memory_emulator_tests/error_cases.rs", + "test": "read_item_not_found" + } + ], + "profile": "hostedEmulatorSmoke", + "tags": [ + "prSmoke", + "item", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + }, + "fixtures": [ + { + "id": "hashV2", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [ + { + "id": "existing", + "seed": true, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "item-1", + "pk": "A", + "value": 1 + } + } + ] + } + ], + "steps": [ + { + "id": "missing", + "action": { + "kind": "sdkOperation", + "operation": "readItem", + "input": { + "id": "missing", + "pk": "A" + } + }, + "expected": { + "outcome": "error", + "status": 404, + "subStatus": 0, + "errorCategory": "notFound" + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "read_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 404 + } + } + }, + { + "id": "wrongPartitionKey", + "action": { + "kind": "sdkOperation", + "operation": "readItem", + "input": { + "id": "item-1", + "pk": "B" + } + }, + "expected": { + "outcome": "error", + "status": 404, + "subStatus": 0, + "errorCategory": "notFound", + "state": { + "originalUnchanged": true + } + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "read_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 404 + } + } + } + ], + "cleanup": { + "deleteDatabase": true + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json new file mode 100644 index 00000000000..7a0d046916c --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json @@ -0,0 +1,126 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.optimistic-concurrency", + "title": "A stale ETag cannot overwrite a newer item", + "requirement": "If-Match accepts the current ETag, rejects a stale ETag with 412, and preserves the successful update.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "dotnet", + "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", + "test": "ItemRequestOptionAccessConditionTest" + } + ], + "profile": "hostedEmulatorSmoke", + "tags": [ + "prSmoke", + "item", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + }, + "fixtures": [ + { + "id": "hashV2", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [ + { + "id": "existing", + "seed": true, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "etag-1", + "pk": "A", + "value": 1 + } + } + ] + } + ], + "steps": [ + { + "id": "replaceCurrent", + "action": { + "kind": "sdkOperation", + "operation": "replaceItem", + "input": { + "id": "etag-1", + "pk": "A", + "value": 2, + "ifMatch": "initialEtag" + } + }, + "expected": { + "outcome": "success", + "status": 200, + "state": { + "value": 2 + } + } + }, + { + "id": "replaceStale", + "action": { + "kind": "sdkOperation", + "operation": "replaceItem", + "input": { + "id": "etag-1", + "pk": "A", + "value": 3, + "ifMatch": "initialEtag" + } + }, + "expected": { + "outcome": "error", + "status": 412, + "subStatus": 0, + "errorCategory": "preconditionFailed", + "state": { + "value": 2 + } + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "replace_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 412 + } + } + } + ], + "cleanup": { + "deleteDatabase": true + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json new file mode 100644 index 00000000000..58dc5046cbc --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json @@ -0,0 +1,122 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.upsert-create-update", + "title": "Upsert creates then updates one identity", + "requirement": "Upsert returns 201 for a missing identity and 200 when replacing it, without creating a duplicate.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "dotnet", + "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", + "test": "UpsertItemTest" + } + ], + "profile": "hostedEmulatorSmoke", + "tags": [ + "prSmoke", + "item" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + }, + "fixtures": [ + { + "id": "hashV2", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [] + } + ], + "steps": [ + { + "id": "upsertCreate", + "action": { + "kind": "sdkOperation", + "operation": "upsertItem", + "input": { + "id": "upsert-1", + "pk": "A", + "value": 1 + } + }, + "expected": { + "outcome": "success", + "status": 201, + "state": { + "value": 1 + } + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "upsert_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 201 + } + } + }, + { + "id": "upsertUpdate", + "action": { + "kind": "sdkOperation", + "operation": "upsertItem", + "input": { + "id": "upsert-1", + "pk": "A", + "value": 2 + } + }, + "expected": { + "outcome": "success", + "status": 200, + "state": { + "value": 2, + "itemCount": 1 + } + }, + "diagnostics": { + "operationName": { + "comparator": "exact", + "value": "upsert_item" + }, + "activityId": { + "comparator": "present" + }, + "effectiveStatus": { + "comparator": "exact", + "value": 200 + } + } + } + ], + "cleanup": { + "deleteDatabase": true + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json new file mode 100644 index 00000000000..0ce6b2b0870 --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json @@ -0,0 +1,77 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "management.capabilities", + "title": "The emulator advertises a versioned capability document", + "requirement": "An external SDK runner can discover protocol and management capabilities without using Rust internals.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "rust", + "path": "sdk/cosmos/azure_data_cosmos_emulator/src/management.rs", + "test": "capabilities_report_configured_protocols" + } + ], + "profile": "hostedEmulatorSmoke", + "tags": [ + "prSmoke", + "management" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "capabilities" + ] + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "capabilities", + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "notApplicable", + "fidelity": "none", + "reason": "The capability document is specific to emulator E2E test orchestration." + } + }, + "fixtures": [ + { + "id": "default", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [] + } + ], + "steps": [ + { + "id": "readCapabilities", + "action": { + "kind": "managementRequest", + "operation": "readCapabilities" + }, + "expected": { + "outcome": "success", + "status": 200, + "state": { + "apiVersion": 1, + "gatewayV1": true + } + } + } + ], + "cleanup": { + "deleteDatabase": false + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json new file mode 100644 index 00000000000..ae61cbdcc4e --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json @@ -0,0 +1,74 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "query.invalid-syntax", + "title": "Invalid query syntax returns bad request", + "requirement": "A deferred query failure is surfaced as a typed 400 error rather than an empty feed.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "dotnet", + "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", + "test": "NegativeQueryTest" + } + ], + "profile": "hostedEmulatorSmoke", + "tags": [ + "prSmoke", + "query", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + }, + "fixtures": [ + { + "id": "hashV2", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [] + } + ], + "steps": [ + { + "id": "invalidQuery", + "action": { + "kind": "sdkOperation", + "operation": "queryItems", + "input": { + "text": "SELECT FROM", + "partitionKey": "A" + } + }, + "expected": { + "outcome": "error", + "status": 400, + "errorCategory": "badRequest" + } + } + ], + "cleanup": { + "deleteDatabase": true + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json new file mode 100644 index 00000000000..493d804499b --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json @@ -0,0 +1,125 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "query.parameterized-filter", + "title": "A parameterized partition query filters and orders results", + "requirement": "Query parameters are bound without string substitution and deterministic ORDER BY results preserve order.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "java", + "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemTest.java", + "test": "queryItems" + } + ], + "profile": "hostedEmulatorSmoke", + "tags": [ + "prSmoke", + "query" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + }, + "fixtures": [ + { + "id": "hashV2", + "container": { + "partitionKey": { + "paths": [ + "/pk" + ], + "kind": "Hash", + "version": 2 + } + }, + "items": [ + { + "id": "item1", + "seed": true, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "item-1", + "pk": "A", + "score": 1 + } + }, + { + "id": "item2", + "seed": true, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "item-2", + "pk": "A", + "score": 2 + } + }, + { + "id": "item3", + "seed": true, + "partitionKeyValues": [ + "A" + ], + "document": { + "id": "item-3", + "pk": "A", + "score": 3 + } + } + ] + } + ], + "steps": [ + { + "id": "query", + "action": { + "kind": "sdkOperation", + "operation": "queryItems", + "input": { + "text": "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score ASC", + "parameters": [ + { + "name": "@pk", + "value": "A" + }, + { + "name": "@min", + "value": 2 + } + ], + "partitionKey": "A" + } + }, + "expected": { + "outcome": "success", + "status": 200, + "state": { + "ids": [ + "item-2", + "item-3" + ] + } + } + } + ], + "cleanup": { + "deleteDatabase": true + } +} diff --git a/sdk/cosmos/e2e_tests/schema/profile.v1.json b/sdk/cosmos/e2e_tests/schema/profile.v1.json new file mode 100644 index 00000000000..e01052553fa --- /dev/null +++ b/sdk/cosmos/e2e_tests/schema/profile.v1.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://azure.github.io/cosmos-sdk-e2e-tests/profile.v1.json", + "title": "Cosmos SDK E2E test profile", + "type": "object", + "additionalProperties": false, + "required": [ + "specVersion", + "id", + "accounts", + "runtimes", + "clients" + ], + "properties": { + "$schema": { + "type": "string" + }, + "specVersion": { + "const": "1.0" + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "accounts": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/account" } + }, + "runtimes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/runtime" } + }, + "clients": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/client" } + } + }, + "$defs": { + "readConsistencyStrategy": { + "enum": ["Default", "Eventual", "Session", "LatestCommitted", "GlobalStrong"] + }, + "account": { + "type": "object", + "additionalProperties": false, + "required": ["id", "writeMode", "consistency", "regions", "replication", "perPartitionFailover"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" }, + "writeMode": { "enum": ["single", "multi"] }, + "consistency": { "enum": ["strong", "boundedStaleness", "session", "consistentPrefix", "eventual"] }, + "regions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { "name": { "type": "string", "minLength": 1 } } + } + }, + "replication": { + "type": "object", + "additionalProperties": false, + "required": ["minDelayMs", "maxDelayMs"], + "properties": { + "minDelayMs": { "type": "integer", "minimum": 0 }, + "maxDelayMs": { "type": "integer", "minimum": 0 } + } + }, + "perPartitionFailover": { "type": "boolean" } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": ["id", "gatewayV2", "ppcb"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" }, + "gatewayV2": { "enum": ["enabled", "disabled", "backendDefault"] }, + "ppcb": { "enum": ["enabled", "disabled", "sdkDefault"] }, + "defaultReadConsistencyStrategy": { "$ref": "#/$defs/readConsistencyStrategy" } + } + }, + "client": { + "type": "object", + "additionalProperties": false, + "required": ["id", "binaryEncoding", "routing"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" }, + "binaryEncoding": { "enum": ["enabled", "disabled", "sdkDefault"] }, + "routing": { "enum": ["proximity", "preferredRegions", "accountOrder"] }, + "defaultReadConsistencyStrategy": { "$ref": "#/$defs/readConsistencyStrategy" } + } + } + } +} diff --git a/sdk/cosmos/e2e_tests/schema/scenario.v1.json b/sdk/cosmos/e2e_tests/schema/scenario.v1.json new file mode 100644 index 00000000000..da085b463d9 --- /dev/null +++ b/sdk/cosmos/e2e_tests/schema/scenario.v1.json @@ -0,0 +1,446 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://azure.github.io/cosmos-sdk-e2e-tests/scenario.v1.json", + "title": "Cosmos SDK E2E test scenario", + "type": "object", + "additionalProperties": false, + "required": [ + "specVersion", + "id", + "title", + "requirement", + "maturity", + "precedents", + "tags", + "backends", + "fixtures", + "steps", + "cleanup" + ], + "oneOf": [ + { "required": ["profile"], "not": { "required": ["executions"] } }, + { "required": ["executions"], "not": { "required": ["profile"] } } + ], + "properties": { + "$schema": { + "type": "string" + }, + "specVersion": { + "const": "1.0" + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(\\.[a-z][a-z0-9-]*)+$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "requirement": { + "type": "string", + "minLength": 1 + }, + "maturity": { + "enum": [ + "candidate", + "stable", + "deprecated" + ] + }, + "precedents": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "sdk", + "path", + "test" + ], + "properties": { + "sdk": { + "enum": [ + "service", + "rust", + "java", + "dotnet", + "python" + ] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "test": { + "type": "string", + "minLength": 1 + } + } + } + }, + "profile": { + "type": "string", + "minLength": 1 + }, + "tags": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "backends": { + "type": "object", + "additionalProperties": false, + "required": [ + "hostedEmulatorGatewayV1", + "hostedEmulatorGatewayV2", + "azureLive" + ], + "properties": { + "hostedEmulatorGatewayV1": { + "$ref": "#/$defs/backend" + }, + "hostedEmulatorGatewayV2": { + "$ref": "#/$defs/backend" + }, + "azureLive": { + "$ref": "#/$defs/backend" + } + } + }, + "fixtures": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/fixture" + } + }, + "executions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/execution" + } + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/step" + } + }, + "cleanup": { + "type": "object", + "additionalProperties": false, + "required": [ + "deleteDatabase" + ], + "properties": { + "deleteDatabase": { + "type": "boolean" + } + } + } + }, + "$defs": { + "execution": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "profile", + "account", + "runtime", + "client", + "readConsistencyStrategy", + "readRegion", + "sessionToken", + "expectedRead" + ], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" }, + "profile": { "type": "string", "minLength": 1 }, + "account": { "type": "string", "minLength": 1 }, + "runtime": { "type": "string", "minLength": 1 }, + "client": { "type": "string", "minLength": 1 }, + "readConsistencyStrategy": { + "enum": ["Inherit", "Default", "Eventual", "Session", "LatestCommitted", "GlobalStrong"] + }, + "readRegion": { "type": "string", "minLength": 1 }, + "sessionToken": { "enum": ["automatic", "createResponse", "none"] }, + "expectedRead": { + "type": "object", + "additionalProperties": false, + "required": ["acceptableInitialStatuses", "terminalStatus"], + "properties": { + "acceptableInitialStatuses": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/status" } + }, + "terminalStatus": { "$ref": "#/$defs/status" }, + "maxWaitMs": { "type": "integer", "minimum": 1 } + } + } + } + }, + "status": { + "type": "object", + "additionalProperties": false, + "required": ["statusCode"], + "properties": { + "statusCode": { "type": "integer", "minimum": 100, "maximum": 599 }, + "subStatusCode": { "type": "integer", "minimum": 0 } + } + }, + "fixture": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "container", + "items" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "container": { + "type": "object", + "additionalProperties": false, + "required": [ + "partitionKey" + ], + "properties": { + "partitionKey": { + "type": "object", + "additionalProperties": false, + "required": [ + "paths", + "kind", + "version" + ], + "properties": { + "paths": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^/[^/]+" + } + }, + "kind": { + "enum": [ + "Hash", + "MultiHash" + ] + }, + "version": { + "enum": [ + 1, + 2 + ] + } + } + } + } + }, + "items": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "seed", + "partitionKeyValues", + "document" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "seed": { + "type": "boolean" + }, + "partitionKeyValues": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "document": { + "type": "object" + } + } + } + } + } + }, + "backend": { + "type": "object", + "additionalProperties": false, + "required": [ + "applicability", + "fidelity" + ], + "properties": { + "applicability": { + "enum": [ + "required", + "supported", + "simulated", + "notApplicable" + ] + }, + "fidelity": { + "enum": [ + "full", + "partial", + "simulated", + "none" + ] + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "requires": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "step": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "action", + "expected" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "action": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "operation" + ], + "properties": { + "kind": { + "enum": [ + "sdkOperation", + "managementRequest" + ] + }, + "operation": { + "type": "string", + "minLength": 1 + }, + "input": {} + } + }, + "expected": { + "type": "object", + "additionalProperties": false, + "required": [ + "outcome", + "status" + ], + "properties": { + "outcome": { + "enum": [ + "success", + "error" + ] + }, + "status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "subStatus": { + "type": "integer", + "minimum": 0 + }, + "errorCategory": { + "type": "string", + "minLength": 1 + }, + "state": {} + } + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "diagnostics": { + "type": "object", + "additionalProperties": false, + "properties": { + "operationName": { + "$ref": "#/$defs/comparison" + }, + "activityId": { + "$ref": "#/$defs/comparison" + }, + "effectiveStatus": { + "$ref": "#/$defs/comparison" + }, + "requestCount": { + "$ref": "#/$defs/comparison" + }, + "regionsContacted": { + "$ref": "#/$defs/comparison" + } + } + }, + "comparison": { + "type": "object", + "additionalProperties": false, + "required": [ + "comparator" + ], + "properties": { + "comparator": { + "enum": [ + "exact", + "present", + "absent", + "contains", + "atLeast", + "atMost", + "setEquals", + "orderedSubsequence" + ] + }, + "value": {} + } + } + } +} diff --git a/sdk/cosmos/e2e_tests/vocabulary/v1.json b/sdk/cosmos/e2e_tests/vocabulary/v1.json new file mode 100644 index 00000000000..be95fd28a7e --- /dev/null +++ b/sdk/cosmos/e2e_tests/vocabulary/v1.json @@ -0,0 +1,18 @@ +{ + "specVersion": "1.0", + "backends": ["hostedEmulatorGatewayV1", "hostedEmulatorGatewayV2", "azureLive"], + "applicability": ["required", "supported", "simulated", "notApplicable"], + "maturity": ["candidate", "stable", "deprecated"], + "operations": [ + "readCapabilities", + "buildClient", + "createItem", + "readItem", + "replaceItem", + "upsertItem", + "deleteItem", + "queryItems" + ], + "errorCategories": ["badRequest", "notFound", "conflict", "preconditionFailed"], + "diagnosticComparators": ["exact", "present", "absent", "contains", "atLeast", "atMost", "setEquals", "orderedSubsequence"] +} diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 index 30c18535996..5273ff490ee 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 @@ -8,6 +8,94 @@ # Work around a temporary issue where Invoke-LoggedCommand, which calls us, needs LASTEXITCODE to be set $global:LASTEXITCODE = 0 +function Test-CosmosE2eScenarioDocuments { + $e2eTestRoot = ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', 'e2e_tests')) + $scenarioSchema = ([System.IO.Path]::Combine($e2eTestRoot, 'schema', 'scenario.v1.json')) + $profileSchema = ([System.IO.Path]::Combine($e2eTestRoot, 'schema', 'profile.v1.json')) + + Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'scenarios')) -Recurse -Filter '*.json' | ForEach-Object { + if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $scenarioSchema)) { + throw "Cosmos E2E scenario failed schema validation: $($_.FullName)" + } + } + Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'profiles')) -Filter '*.json' | ForEach-Object { + if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $profileSchema)) { + throw "Cosmos E2E profile failed schema validation: $($_.FullName)" + } + } +} + +function New-CosmosE2eEmulatorConfig { + param( + [Parameter(Mandatory)] + [string] $Profile, + + [Parameter(Mandatory)] + [bool] $GatewayV2Enabled + ) + + if ($Profile -notmatch '^[a-zA-Z][a-zA-Z0-9]*$') { + throw "Invalid AZURE_COSMOS_E2E_PROFILE value '$Profile'." + } + $e2eTestRoot = ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', 'e2e_tests')) + $profilePath = ([System.IO.Path]::Combine($e2eTestRoot, 'profiles', "$Profile.json")) + if (-not (Test-Path $profilePath)) { + throw "E2E profile '$Profile' does not exist at '$profilePath'." + } + $profileDocument = Get-Content $profilePath -Raw | ConvertFrom-Json + $accountDefinitions = @($profileDocument.accounts) + $accountDefinition = if ($env:AZURE_COSMOS_E2E_ACCOUNT) { + @($accountDefinitions | Where-Object { $_.id -eq $env:AZURE_COSMOS_E2E_ACCOUNT }) + } + elseif ($accountDefinitions.Count -eq 1) { + @($accountDefinitions[0]) + } + else { + throw "AZURE_COSMOS_E2E_ACCOUNT is required for profile '$Profile'." + } + if ($accountDefinition.Count -ne 1) { + throw "Profile '$Profile' does not contain exactly one account named '$env:AZURE_COSMOS_E2E_ACCOUNT'." + } + $accountDefinition = $accountDefinition[0] + $regions = @($accountDefinition.regions | ForEach-Object { + $region = [ordered]@{ + name = [string]$_.name + gatewayPort = 0 + } + if ($GatewayV2Enabled) { + $region.gateway20Port = 0 + } + [pscustomobject]$region + }) + $configuration = [ordered]@{ + account = [ordered]@{ + id = "e2e-$Profile-$($accountDefinition.id)" + writeMode = [string]$accountDefinition.writeMode + consistency = [string]$accountDefinition.consistency + perPartitionFailover = [bool]$accountDefinition.perPartitionFailover + throttling = $false + regions = $regions + replication = [ordered]@{ + minDelayMs = [uint64]$accountDefinition.replication.minDelayMs + maxDelayMs = [uint64]$accountDefinition.replication.maxDelayMs + maxBufferedReplications = 10000 + } + } + management = @{ port = 0 } + databases = @() + } + $mode = if ($GatewayV2Enabled) { 'v2' } else { 'v1' } + $path = ([System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "azure-cosmos-e2e-$Profile-$($accountDefinition.id)-$mode.json")) + $configuration | ConvertTo-Json -Depth 10 | Set-Content $path + return $path +} + +if (-not $env:AZURE_COSMOS_E2E_TESTS_VALIDATED) { + Test-CosmosE2eScenarioDocuments + $env:AZURE_COSMOS_E2E_TESTS_VALIDATED = '1' + Write-Host 'Validated Cosmos SDK E2E scenario documents.' +} + # Append COSMOS_RUSTFLAGS (from test-resources.bicep) to RUSTFLAGS if present if ($env:COSMOS_RUSTFLAGS) { $env:RUSTFLAGS = "$($env:RUSTFLAGS) $($env:COSMOS_RUSTFLAGS)" @@ -58,6 +146,11 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { } $ready = $false $expectedGateway20 = $env:AZURE_COSMOS_EMULATOR_FLAVOR -eq 'inmemory-v2' + if ($env:AZURE_COSMOS_E2E_PROFILE) { + $configuration = New-CosmosE2eEmulatorConfig ` + -Profile $env:AZURE_COSMOS_E2E_PROFILE ` + -GatewayV2Enabled $expectedGateway20 + } $managementEndpoint = $env:AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT $accountEndpoint = $env:AZURE_COSMOS_INMEMORY_ACCOUNT_ENDPOINT if ($managementEndpoint -and $accountEndpoint) { @@ -173,8 +266,13 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $env:AZURE_COSMOS_CONNECTION_STRING = "AccountEndpoint=$accountEndpoint;AccountKey=$emulatorKey;" $env:AZURE_COSMOS_TEST_MODE = 'required' $env:RUSTFLAGS = $env:RUSTFLAGS -replace '\s*--cfg=test_category="[^"]*"', '' - $env:RUSTFLAGS = "$($env:RUSTFLAGS) --cfg=test_category=`"emulator_inmemory`"" - if ($expectedGateway20) { + if ($env:AZURE_COSMOS_E2E_PROFILE) { + $env:RUSTFLAGS = "$($env:RUSTFLAGS) --cfg=test_category=`"e2e`"" + } + else { + $env:RUSTFLAGS = "$($env:RUSTFLAGS) --cfg=test_category=`"emulator_inmemory`"" + } + if ($expectedGateway20 -and -not $env:AZURE_COSMOS_E2E_PROFILE) { $env:RUSTFLAGS = "$($env:RUSTFLAGS) --cfg=test_category=`"emulator_inmemory_gateway_v2`"" } $env:RUST_TEST_THREADS = '1' From 15ee8606da14859d7fefaee7b5e49bf132c8464a Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Thu, 10 Sep 2026 16:10:04 +0000 Subject: [PATCH 02/19] Change what json files capture vs. source code --- .../tests/e2e_test_cases/bootstrap_primary.rs | 28 + .../tests/e2e_test_cases/capabilities.rs | 52 + .../tests/e2e_test_cases/catalog.rs | 1051 +++++------------ .../diagnostics_success_and_error.rs | 55 + .../tests/e2e_test_cases/fixture.rs | 26 +- .../e2e_test_cases/item_create_conflict.rs | 143 +++ .../tests/e2e_test_cases/item_lifecycle.rs | 662 +++++++++++ .../tests/e2e_test_cases/item_not_found.rs | 53 + .../item_optimistic_concurrency.rs | 73 ++ .../tests/e2e_test_cases/item_upsert.rs | 61 + .../tests/e2e_test_cases/mod.rs | 704 +---------- .../e2e_test_cases/query_invalid_syntax.rs | 39 + .../query_parameterized_filter.rs | 48 + .../tests/e2e_test_cases/support.rs | 87 ++ sdk/cosmos/e2e_tests/README.md | 172 ++- .../e2e_tests/implementations/rust.json | 20 +- .../e2e_tests/profiles/legacyGatewayV1.json | 60 +- .../e2e_tests/profiles/targetDefault.json | 66 +- .../scenarios/bootstrap/primary-success.json | 38 +- .../diagnostics/success-and-error.json | 115 +- .../scenarios/items/create-conflict.json | 167 +-- .../e2e_tests/scenarios/items/lifecycle.json | 569 +-------- .../items/not-found-wrong-partition-key.json | 100 +- .../items/optimistic-concurrency.json | 92 +- .../scenarios/items/upsert-create-update.json | 89 +- .../scenarios/management/capabilities.json | 39 +- .../scenarios/queries/invalid-syntax.json | 40 +- .../queries/parameterized-filter.json | 92 +- sdk/cosmos/e2e_tests/schema/profile.v1.json | 3 +- sdk/cosmos/e2e_tests/schema/scenario.v1.json | 349 +----- sdk/cosmos/e2e_tests/vocabulary/v1.json | 18 - 31 files changed, 1847 insertions(+), 3264 deletions(-) create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs create mode 100644 sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs delete mode 100644 sdk/cosmos/e2e_tests/vocabulary/v1.json diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs new file mode 100644 index 00000000000..11b1f515838 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; + +use crate::e2e_test_cases::{ + fixture::{build_client, TestResult}, + support::{hosted_only, should_run}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn bootstrap_primary_endpoint() -> TestResult { + if !should_run("bootstrap.primary-success")? { + return Ok(()); + } + let client = build_client().await?; + assert!(hosted_only()); + let database_id = format!("e2e-bootstrap-{}", azure_core::Uuid::new_v4()); + let response = client.create_database(&database_id, None).await?; + assert_eq!(response.status().status_code(), StatusCode::Created); + response.into_model()?; + client.database_client(&database_id).delete(None).await?; + Ok(()) +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs new file mode 100644 index 00000000000..ded525f2b6f --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use serde::Deserialize; + +use crate::e2e_test_cases::{fixture::TestResult, support::should_run}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CapabilityDocument { + api_version: u32, + emulator_version: String, + protocols: ProtocolCapabilities, + data_plane: Vec, + management_actions: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolCapabilities { + gateway_v1: bool, + gateway_v2: bool, +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn capability_document_is_versioned() -> TestResult { + if !should_run("management.capabilities")? { + return Ok(()); + } + let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; + let response = reqwest::Client::new() + .get(url::Url::parse(&management_endpoint)?.join("capabilities")?) + .send() + .await?; + assert_eq!(response.status(), reqwest::StatusCode::OK); + let capabilities: CapabilityDocument = serde_json::from_slice(&response.bytes().await?)?; + assert_eq!(capabilities.api_version, 1); + assert!(!capabilities.emulator_version.is_empty()); + assert!(capabilities.protocols.gateway_v1); + assert!(capabilities.data_plane.iter().any(|value| value == "item")); + assert!(capabilities + .management_actions + .iter() + .any(|value| value == "partitionSplit")); + let expects_v2 = std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR").as_deref() == Ok("inmemory-v2"); + assert_eq!(capabilities.protocols.gateway_v2, expects_v2); + Ok(()) +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs index 88d8361f967..69910d782b6 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs @@ -3,19 +3,20 @@ #![allow(dead_code)] -use std::{ - borrow::Cow, - collections::{BTreeMap, BTreeSet}, -}; +use std::collections::{BTreeMap, BTreeSet}; -use azure_data_cosmos::{ - diagnostics::DiagnosticsContext, - models::{PartitionKeyDefinition, PartitionKeyKind, PartitionKeyValue, PartitionKeyVersion}, - PartitionKey, -}; use serde::Deserialize; use serde_json::Value; +const DEFAULT_PROFILE: &str = "hostedEmulatorSmoke"; +const SCENARIO_SCHEMA_REFERENCE: &str = "../../schema/scenario.v1.json"; +const PROFILE_SCHEMA_REFERENCE: &str = "../schema/profile.v1.json"; +const BACKENDS: [&str; 3] = [ + "azureLive", + "hostedEmulatorGatewayV1", + "hostedEmulatorGatewayV2", +]; + const SCENARIOS: &[&str] = &[ include_str!("../../../e2e_tests/scenarios/management/capabilities.json"), include_str!("../../../e2e_tests/scenarios/bootstrap/primary-success.json"), @@ -37,152 +38,87 @@ const PROFILES: &[&str] = &[ include_str!("../../../e2e_tests/profiles/readConsistencyOverrideMatrix.json"), ]; -const VOCABULARY: &str = include_str!("../../../e2e_tests/vocabulary/v1.json"); const RUST_IMPLEMENTATIONS: &str = include_str!("../../../e2e_tests/implementations/rust.json"); +const CONSISTENCY_MATRIX: &str = include_str!("../../../e2e-consistency-matrix.json"); +const OVERRIDE_MATRIX: &str = include_str!("../../../e2e-read-consistency-override-matrix.json"); const SCENARIO_SCHEMA: &str = include_str!("../../../e2e_tests/schema/scenario.v1.json"); const PROFILE_SCHEMA: &str = include_str!("../../../e2e_tests/schema/profile.v1.json"); #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Scenario { +struct Scenario { #[serde(rename = "$schema")] schema: String, spec_version: String, - pub id: String, + id: String, title: String, requirement: String, - maturity: String, + maturity: Maturity, precedents: Vec, - profile: Option, + profiles: Vec, tags: Vec, backends: BTreeMap, - pub fixtures: Vec, - #[serde(default)] - pub executions: Vec, - pub steps: Vec, - cleanup: Cleanup, } #[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct Precedent { - sdk: String, - path: String, - test: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct Backend { - applicability: String, - fidelity: String, - reason: Option, - #[serde(default)] - requires: Vec, +#[serde(rename_all = "camelCase")] +enum Maturity { + Candidate, + Stable, + Deprecated, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] -pub struct Fixture { - pub id: String, - container: ContainerSetup, - pub items: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ContainerSetup { - partition_key: PartitionKeySetup, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PartitionKeySetup { - paths: Vec, - kind: String, - version: u8, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct FixtureItem { - pub id: String, - pub seed: bool, - partition_key_values: Vec, - pub document: Value, +struct Precedent { + sdk: ReferenceSdk, + path: String, + test: String, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Execution { - pub id: String, - pub profile: String, - pub account: String, - pub runtime: String, - pub client: String, - pub read_consistency_strategy: String, - pub read_region: String, - pub session_token: String, - pub expected_read: ExpectedRead, +#[serde(rename_all = "lowercase")] +enum ReferenceSdk { + Service, + Rust, + Java, + Dotnet, + Python, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExpectedRead { - pub acceptable_initial_statuses: Vec, - pub terminal_status: ExpectedStatus, - pub max_wait_ms: Option, +struct Backend { + applicability: Applicability, + fidelity: Fidelity, + reason: Option, + #[serde(default)] + requires: Vec, } #[derive(Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExpectedStatus { - pub status_code: u16, - pub sub_status_code: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Step { - pub id: String, - action: Action, - pub expected: Expected, - pub diagnostics: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct Action { - kind: String, - operation: String, - input: Option, +#[serde(rename_all = "camelCase")] +enum Capability { + Capabilities, + GatewayV2, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Expected { - outcome: String, - pub status: u16, - pub sub_status: Option, - error_category: Option, - state: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct DiagnosticsExpectation { - operation_name: Option, - activity_id: Option, - effective_status: Option, - request_count: Option, - regions_contacted: Option, +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum Applicability { + Required, + Supported, + Simulated, + NotApplicable, } #[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct Comparison { - comparator: String, - value: Option, +#[serde(rename_all = "camelCase")] +enum Fidelity { + Full, + Partial, + Simulated, + None, } #[derive(Debug, Deserialize)] @@ -225,8 +161,8 @@ struct ReplicationDefinition { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RuntimeDefinition { pub id: String, - gateway_v2: String, - ppcb: String, + pub gateway_v2: String, + pub ppcb: String, pub default_read_consistency_strategy: Option, } @@ -234,23 +170,11 @@ pub struct RuntimeDefinition { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ClientDefinition { pub id: String, - binary_encoding: String, - routing: String, + pub binary_encoding: String, + pub routing: String, pub default_read_consistency_strategy: Option, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct Vocabulary { - spec_version: String, - backends: Vec, - applicability: Vec, - maturity: Vec, - operations: Vec, - error_categories: Vec, - diagnostic_comparators: Vec, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ImplementationMap { @@ -265,67 +189,15 @@ struct ImplementationMap { struct Implementation { id: String, test: String, - status: String, + status: ImplementationStatus, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct Cleanup { - delete_database: bool, -} - -pub fn load_scenarios() -> Result, String> { - SCENARIOS - .iter() - .map(|json| serde_json::from_str(json).map_err(|error| error.to_string())) - .collect() -} - -pub fn scenario(id: &str) -> Scenario { - load_scenarios() - .expect("E2E scenario catalog must deserialize") - .into_iter() - .find(|scenario| scenario.id == id) - .unwrap_or_else(|| panic!("E2E scenario '{id}' does not exist")) -} - -pub fn profile(id: &str) -> Profile { - PROFILES - .iter() - .map(|json| serde_json::from_str::(json).expect("E2E profile must deserialize")) - .find(|profile| profile.id == id) - .unwrap_or_else(|| panic!("E2E profile '{id}' does not exist")) -} - -impl Scenario { - pub fn step(&self, id: &str) -> &Step { - self.steps - .iter() - .find(|step| step.id == id) - .unwrap_or_else(|| panic!("scenario '{}' has no step '{id}'", self.id)) - } - - pub fn fixture(&self, id: &str) -> &Fixture { - self.fixtures - .iter() - .find(|fixture| fixture.id == id) - .unwrap_or_else(|| panic!("scenario '{}' has no fixture '{id}'", self.id)) - } - - pub fn executions_for_configuration<'a>( - &'a self, - profile: &'a str, - account: &'a str, - runtime: &'a str, - client: &'a str, - ) -> impl Iterator + 'a { - self.executions.iter().filter(move |execution| { - execution.profile == profile - && execution.account == account - && execution.runtime == runtime - && execution.client == client - }) - } +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum ImplementationStatus { + Active, + Planned, + Unsupported, } impl Profile { @@ -351,110 +223,39 @@ impl Profile { } } -impl Fixture { - pub fn item(&self, id: &str) -> &FixtureItem { - self.items - .iter() - .find(|item| item.id == id) - .unwrap_or_else(|| panic!("fixture '{}' has no item '{id}'", self.id)) - } - - pub fn partition_key_definition(&self) -> Result { - let kind = match self.container.partition_key.kind.as_str() { - "Hash" => PartitionKeyKind::Hash, - "MultiHash" => PartitionKeyKind::MultiHash, - kind => { - return Err(format!( - "fixture '{}' has unknown PK kind '{kind}'", - self.id - )) - } - }; - let version = match self.container.partition_key.version { - 1 => PartitionKeyVersion::V1, - 2 => PartitionKeyVersion::V2, - version => return Err(format!("fixture '{}' has PK version {version}", self.id)), - }; - Ok(PartitionKeyDefinition::new( - self.container - .partition_key - .paths - .iter() - .cloned() - .map(Cow::Owned) - .collect(), - ) - .with_kind(kind) - .with_version(version)) - } -} - -impl FixtureItem { - pub fn partition_key(&self) -> Result { - let values = self - .partition_key_values - .iter() - .map(partition_key_value) - .collect::, _>>()?; - Ok(PartitionKey::from(values)) - } - - pub fn document_id(&self) -> Result<&str, String> { - self.document - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| format!("fixture item '{}' has no string document id", self.id)) - } +fn load_scenarios() -> Result, String> { + SCENARIOS + .iter() + .map(|json| serde_json::from_str(json).map_err(|error| error.to_string())) + .collect() } -impl ExpectedStatus { - pub fn matches(&self, status_code: u16, sub_status_code: Option) -> bool { - self.status_code == status_code - && self - .sub_status_code - .is_none_or(|expected| expected == sub_status_code.unwrap_or(0)) - } - - fn overlaps(&self, other: &Self) -> bool { - self.status_code == other.status_code - && (self.sub_status_code.is_none() - || other.sub_status_code.is_none() - || self.sub_status_code == other.sub_status_code) - } +fn load_profiles() -> Result, String> { + PROFILES + .iter() + .map(|json| serde_json::from_str(json).map_err(|error| error.to_string())) + .collect() } -impl ExpectedRead { - pub fn is_terminal(&self, status_code: u16, sub_status_code: Option) -> bool { - self.terminal_status.matches(status_code, sub_status_code) - } - - pub fn is_acceptable_initial(&self, status_code: u16, sub_status_code: Option) -> bool { - self.acceptable_initial_statuses - .iter() - .any(|expected| expected.matches(status_code, sub_status_code)) +pub fn selected_profile_for(scenario_id: &str) -> Result, String> { + let selected = + std::env::var("AZURE_COSMOS_E2E_PROFILE").unwrap_or_else(|_| DEFAULT_PROFILE.to_owned()); + let scenarios = load_scenarios()?; + let scenario = scenarios + .iter() + .find(|scenario| scenario.id == scenario_id) + .ok_or_else(|| format!("E2E scenario '{scenario_id}' does not exist"))?; + let profiles = load_profiles()?; + if !profiles.iter().any(|profile| profile.id == selected) { + return Err(format!("E2E profile '{selected}' does not exist")); } -} - -fn partition_key_value(value: &Value) -> Result { - match value { - Value::String(value) => Ok(value.clone().into()), - Value::Number(value) => value - .as_f64() - .map(PartitionKeyValue::from) - .ok_or_else(|| format!("partition key number '{value}' is not finite")), - Value::Bool(value) => Ok((*value).into()), - Value::Null => Ok(PartitionKey::NULL), - value => Err(format!("unsupported partition key value '{value}'")), + if !scenario.profiles.contains(&selected) { + return Ok(None); } + Ok(profiles.into_iter().find(|profile| profile.id == selected)) } pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { - let vocabulary: Vocabulary = - serde_json::from_str(VOCABULARY).map_err(|error| error.to_string())?; - if vocabulary.spec_version != "1.0" { - return Err("unsupported vocabulary version".to_owned()); - } - for (name, schema) in [("scenario", SCENARIO_SCHEMA), ("profile", PROFILE_SCHEMA)] { let schema: Value = serde_json::from_str(schema) .map_err(|error| format!("{name} schema is not JSON: {error}"))?; @@ -465,13 +266,14 @@ pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { } } - let profiles: Vec = PROFILES - .iter() - .map(|json| serde_json::from_str(json).map_err(|error| error.to_string())) - .collect::>()?; + let profiles = load_profiles()?; let mut profile_ids = BTreeSet::new(); for profile in &profiles { - if profile.spec_version != "1.0" || !profile_ids.insert(profile.id.as_str()) { + if profile.schema != PROFILE_SCHEMA_REFERENCE + || profile.spec_version != "1.0" + || !valid_camel_id(&profile.id) + || !profile_ids.insert(profile.id.as_str()) + { return Err(format!("invalid or duplicate profile '{}'", profile.id)); } for (axis, ids) in [ @@ -511,6 +313,11 @@ pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { for account in &profile.accounts { if account.regions.is_empty() || account.replication.min_delay_ms > account.replication.max_delay_ms + || !matches!(account.write_mode.as_str(), "single" | "multi") + || !matches!( + account.consistency.as_str(), + "strong" | "boundedStaleness" | "session" | "consistentPrefix" | "eventual" + ) { return Err(format!( "profile '{}' account '{}' has invalid regions or replication delay", @@ -518,12 +325,51 @@ pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { )); } } + if profile.runtimes.iter().any(|runtime| { + !matches!( + runtime.gateway_v2.as_str(), + "enabled" | "disabled" | "backendDefault" + ) || !matches!(runtime.ppcb.as_str(), "enabled" | "disabled" | "sdkDefault") + || !valid_optional_read_strategy( + runtime.default_read_consistency_strategy.as_deref(), + ) + }) || profile.clients.iter().any(|client| { + !matches!( + client.binary_encoding.as_str(), + "enabled" | "disabled" | "sdkDefault" + ) || !matches!( + client.routing.as_str(), + "proximity" | "preferredRegions" | "accountOrder" + ) || !valid_optional_read_strategy(client.default_read_consistency_strategy.as_deref()) + }) { + return Err(format!( + "profile '{}' has invalid setup options", + profile.id + )); + } } + validate_pipeline_matrix( + CONSISTENCY_MATRIX, + profiles + .iter() + .find(|profile| profile.id == "lifecycleConsistencyMatrix") + .expect("consistency profile must be registered"), + )?; + validate_pipeline_matrix( + OVERRIDE_MATRIX, + profiles + .iter() + .find(|profile| profile.id == "readConsistencyOverrideMatrix") + .expect("override profile must be registered"), + )?; let scenarios = load_scenarios()?; let mut scenario_ids = BTreeSet::new(); for scenario in &scenarios { - if scenario.spec_version != "1.0" { + if scenario.schema != SCENARIO_SCHEMA_REFERENCE + || scenario.spec_version != "1.0" + || !valid_scenario_id(&scenario.id) + { return Err(format!( "scenario '{}' has an unsupported version", scenario.id @@ -532,321 +378,69 @@ pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { if !scenario_ids.insert(scenario.id.as_str()) { return Err(format!("duplicate scenario id '{}'", scenario.id)); } - if let Some(profile) = &scenario.profile { - if !profile_ids.contains(profile.as_str()) { - return Err(format!( - "scenario '{}' references unknown profile '{}'", - scenario.id, profile - )); - } - } - if scenario.profile.is_some() != scenario.executions.is_empty() { + if scenario.title.is_empty() + || scenario.requirement.is_empty() + || scenario.precedents.is_empty() + || scenario.profiles.is_empty() + || scenario.tags.is_empty() + || scenario + .precedents + .iter() + .any(|precedent| precedent.path.is_empty() || precedent.test.is_empty()) + { return Err(format!( - "scenario '{}' must declare either one profile or an execution matrix", + "scenario '{}' is missing required metadata", scenario.id )); } - if !vocabulary.maturity.contains(&scenario.maturity) { - return Err(format!("scenario '{}' has unknown maturity", scenario.id)); - } - if scenario.precedents.is_empty() - || scenario.tags.is_empty() - || scenario.fixtures.is_empty() - || scenario.steps.is_empty() + let selected_profiles: BTreeSet<_> = scenario.profiles.iter().map(String::as_str).collect(); + let tags: BTreeSet<_> = scenario.tags.iter().map(String::as_str).collect(); + if selected_profiles.len() != scenario.profiles.len() + || !selected_profiles.is_subset(&profile_ids) + || tags.len() != scenario.tags.len() { return Err(format!( - "scenario '{}' is missing required evidence", + "scenario '{}' references an unknown or duplicate profile", scenario.id )); } - let mut fixture_ids = BTreeSet::new(); - for fixture in &scenario.fixtures { - if !fixture_ids.insert(fixture.id.as_str()) { - return Err(format!( - "scenario '{}' has duplicate fixture '{}'", - scenario.id, fixture.id - )); - } - let definition = fixture.partition_key_definition()?; - let path_count = definition.paths().len(); - if path_count == 0 - || (definition.kind() == PartitionKeyKind::Hash && path_count != 1) - || (definition.kind() == PartitionKeyKind::MultiHash - && (path_count < 2 || definition.version() != PartitionKeyVersion::V2)) - { - return Err(format!( - "scenario '{}' fixture '{}' has an invalid partition key definition", - scenario.id, fixture.id - )); - } - let mut item_ids = BTreeSet::new(); - for item in &fixture.items { - if !item_ids.insert(item.id.as_str()) { - return Err(format!( - "scenario '{}' fixture '{}' has duplicate item '{}'", - scenario.id, fixture.id, item.id - )); - } - if item.partition_key_values.len() != path_count { - return Err(format!( - "scenario '{}' fixture '{}' item '{}' has {} partition-key values for {} paths", - scenario.id, - fixture.id, - item.id, - item.partition_key_values.len(), - path_count - )); - } - item.partition_key()?; - item.document_id()?; - } - } - let mut execution_ids = BTreeSet::new(); - for execution in &scenario.executions { - if !execution_ids.insert(execution.id.as_str()) { - return Err(format!( - "scenario '{}' has duplicate execution '{}'", - scenario.id, execution.id - )); - } - if !profile_ids.contains(execution.profile.as_str()) { - return Err(format!( - "scenario '{}' execution '{}' references unknown profile '{}'", - scenario.id, execution.id, execution.profile - )); - } - let selected_profile = profiles - .iter() - .find(|profile| profile.id == execution.profile) - .expect("profile existence checked above"); - if !selected_profile - .accounts - .iter() - .any(|definition| definition.id == execution.account) - || !selected_profile - .runtimes - .iter() - .any(|definition| definition.id == execution.runtime) - || !selected_profile - .clients - .iter() - .any(|definition| definition.id == execution.client) - { - return Err(format!( - "scenario '{}' execution '{}' references a missing profile matrix cell", - scenario.id, execution.id - )); - } - let expected = &execution.expected_read; - let unique_initial_statuses: BTreeSet<_> = - expected.acceptable_initial_statuses.iter().collect(); - if unique_initial_statuses.len() != expected.acceptable_initial_statuses.len() - || expected - .acceptable_initial_statuses - .iter() - .enumerate() - .any(|(index, status)| { - expected.acceptable_initial_statuses[index + 1..] - .iter() - .any(|other| status.overlaps(other)) - }) - || expected - .acceptable_initial_statuses - .iter() - .chain(std::iter::once(&expected.terminal_status)) - .any(|status| !(100..=599).contains(&status.status_code)) - || expected - .acceptable_initial_statuses - .iter() - .any(|status| status.overlaps(&expected.terminal_status)) - { - return Err(format!( - "scenario '{}' execution '{}' has invalid or overlapping status expectations", - scenario.id, execution.id - )); - } - if expected.acceptable_initial_statuses.is_empty() { - if expected.max_wait_ms.is_some() { - return Err(format!( - "scenario '{}' execution '{}' declares a wait without transient statuses", - scenario.id, execution.id - )); - } - } else if expected.max_wait_ms.is_none() { - return Err(format!( - "scenario '{}' execution '{}' must bound retries for transient statuses", - scenario.id, execution.id - )); - } - let selected_account = selected_profile - .accounts - .iter() - .find(|definition| definition.id == execution.account) - .expect("account existence checked above"); - if execution.read_consistency_strategy == "GlobalStrong" - && selected_account.consistency != "strong" - && (!expected.acceptable_initial_statuses.is_empty() - || !expected.is_terminal(400, None)) - { - return Err(format!( - "scenario '{}' execution '{}' must reject GlobalStrong on a non-Strong profile", - scenario.id, execution.id - )); - } - } - if scenario.id == "item.lifecycle" { - let required_strategies: BTreeSet<_> = [ - "Default", - "Eventual", - "Session", - "LatestCommitted", - "GlobalStrong", - ] - .into_iter() - .collect(); - for account in [ - "strong", - "boundedStaleness", - "session", - "consistentPrefix", - "eventual", - ] { - let actual: BTreeSet<_> = scenario - .executions_for_configuration( - "lifecycleConsistencyMatrix", - account, - "unset", - "unset", - ) - .map(|execution| execution.read_consistency_strategy.as_str()) - .collect(); - if actual != required_strategies { - return Err(format!( - "item.lifecycle account '{account}' must cover every read consistency strategy; got {actual:?}" - )); - } - } - let override_profile = profiles - .iter() - .find(|profile| profile.id == "readConsistencyOverrideMatrix") - .expect("override profile must exist"); - for runtime in &override_profile.runtimes { - for client in &override_profile.clients { - let actual: BTreeSet<_> = scenario - .executions_for_configuration( - "readConsistencyOverrideMatrix", - "session", - &runtime.id, - &client.id, - ) - .map(|execution| execution.read_consistency_strategy.as_str()) - .collect(); - let required: BTreeSet<_> = - ["Inherit", "Default", "Eventual"].into_iter().collect(); - if actual != required { - return Err(format!( - "item.lifecycle override cell runtime='{}' client='{}' has incomplete operation coverage: {actual:?}", - runtime.id, client.id - )); - } - } - } - } - if scenario.backends.len() != vocabulary.backends.len() { + let backend_names: BTreeSet<_> = scenario.backends.keys().map(String::as_str).collect(); + if backend_names != BACKENDS.into_iter().collect() { return Err(format!( "scenario '{}' has incomplete backend applicability", scenario.id )); } - for backend_name in &vocabulary.backends { - let backend = scenario.backends.get(backend_name).ok_or_else(|| { - format!("scenario '{}' omits backend '{backend_name}'", scenario.id) - })?; - if !vocabulary.applicability.contains(&backend.applicability) { - return Err(format!( - "scenario '{}' has unknown applicability", - scenario.id - )); - } - if backend.applicability == "notApplicable" && backend.reason.is_none() { + for (backend_name, backend) in &scenario.backends { + if backend.applicability == Applicability::NotApplicable && backend.reason.is_none() { return Err(format!( "scenario '{}' must explain why '{backend_name}' is not applicable", scenario.id )); } - } - - let mut step_ids = BTreeSet::new(); - for step in &scenario.steps { - if !step_ids.insert(step.id.as_str()) { - return Err(format!( - "scenario '{}' has duplicate step '{}'", - scenario.id, step.id - )); - } - if !vocabulary.operations.contains(&step.action.operation) { + let requirements: BTreeSet<_> = backend.requires.iter().collect(); + if requirements.len() != backend.requires.len() { return Err(format!( - "scenario '{}' uses unknown operation '{}'", - scenario.id, step.action.operation - )); - } - for item_ref in [ - step.action - .input - .as_ref() - .and_then(|input| input.get("itemRef")) - .and_then(Value::as_str), - step.expected - .state - .as_ref() - .and_then(|state| state.get("itemRef")) - .and_then(Value::as_str), - ] - .into_iter() - .flatten() - { - for fixture in &scenario.fixtures { - if !fixture.items.iter().any(|item| item.id == item_ref) { - return Err(format!( - "scenario '{}' step '{}' references missing item '{}' in fixture '{}'", - scenario.id, step.id, item_ref, fixture.id - )); - } - } - } - if step.expected.outcome == "error" - && step - .expected - .error_category - .as_ref() - .is_some_and(|category| !vocabulary.error_categories.contains(category)) - { - return Err(format!( - "scenario '{}' has unknown error category", + "scenario '{}' has duplicate requirements for '{backend_name}'", scenario.id )); } - if let Some(diagnostics) = &step.diagnostics { - for comparison in diagnostics.comparisons() { - if !vocabulary - .diagnostic_comparators - .contains(&comparison.comparator) - { - return Err(format!( - "scenario '{}' uses unknown diagnostics comparator '{}'", - scenario.id, comparison.comparator - )); - } - let needs_value = - !matches!(comparison.comparator.as_str(), "present" | "absent"); - if needs_value != comparison.value.is_some() { - return Err(format!( - "scenario '{}' comparator '{}' has an invalid value", - scenario.id, comparison.comparator - )); - } - } - } + } + } + let referenced_profiles: BTreeSet<_> = scenarios + .iter() + .flat_map(|scenario| scenario.profiles.iter().map(String::as_str)) + .collect(); + if let Ok(selected_profile) = std::env::var("AZURE_COSMOS_E2E_PROFILE") { + if !profile_ids.contains(selected_profile.as_str()) { + return Err(format!( + "selected E2E profile '{selected_profile}' does not exist" + )); + } + if !referenced_profiles.contains(selected_profile.as_str()) { + return Err(format!( + "selected E2E profile '{selected_profile}' has no active scenarios" + )); } } @@ -873,7 +467,8 @@ pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { implementation.id )); } - if implementation.status == "active" && !known_tests.contains(implementation.test.as_str()) + if implementation.status == ImplementationStatus::Active + && !known_tests.contains(implementation.test.as_str()) { return Err(format!( "scenario '{}' references missing test '{}'", @@ -887,194 +482,114 @@ pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { Ok(()) } -impl DiagnosticsExpectation { - fn comparisons(&self) -> impl Iterator { - [ - self.operation_name.as_ref(), - self.activity_id.as_ref(), - self.effective_status.as_ref(), - self.request_count.as_ref(), - self.regions_contacted.as_ref(), - ] - .into_iter() - .flatten() - } -} - -pub fn assert_status(step: &Step, actual: u16, sub_status: Option) { - assert_eq!( - actual, step.expected.status, - "status for step '{}'", - step.id - ); - if let Some(expected) = step.expected.sub_status { - assert_eq!( - sub_status.unwrap_or(0), - expected, - "substatus for step '{}'", - step.id - ); - } -} - -pub fn assert_diagnostics(step: &Step, diagnostics: &DiagnosticsContext) { - let Some(contract) = &step.diagnostics else { - return; - }; - if let Some(expected) = &contract.operation_name { - assert_string( - expected, - diagnostics.operation_name(), - "operationName", - &step.id, - ); - } - if let Some(expected) = &contract.activity_id { - let actual = diagnostics.activity_id().to_string(); - assert_string(expected, Some(actual.as_str()), "activityId", &step.id); - } - if let Some(expected) = &contract.effective_status { - let actual = diagnostics - .effective_status() - .map(|status| u16::from(status.status_code())); - assert_number(expected, actual.map(u64::from), "effectiveStatus", &step.id); - } - if let Some(expected) = &contract.request_count { - assert_number( - expected, - Some(diagnostics.request_count() as u64), - "requestCount", - &step.id, - ); - } - if let Some(expected) = &contract.regions_contacted { - let contacted_regions = diagnostics.regions_contacted(); - let regions: Vec<_> = contacted_regions - .iter() - .map(|region| region.as_str()) - .collect(); - let expected_regions = expected - .value - .as_ref() - .and_then(Value::as_array) - .expect("region comparison requires an array"); - if expected.comparator == "contains" { - for region in expected_regions { - let region = region.as_str().expect("region must be a string"); - assert!( - regions.contains(®ion), - "diagnostics field regionsContacted for step '{}' did not contain '{region}': {regions:?}", - step.id - ); - } - } - } -} - -fn assert_string(expected: &Comparison, actual: Option<&str>, field: &str, step: &str) { - match expected.comparator.as_str() { - "present" => assert!( - actual.is_some_and(|value| !value.is_empty()), - "{field} for step '{step}' must be present" - ), - "absent" => assert!(actual.is_none(), "{field} for step '{step}' must be absent"), - "exact" => assert_eq!( - actual, - expected.value.as_ref().and_then(Value::as_str), - "diagnostics field {field} for step '{step}'" +fn valid_optional_read_strategy(strategy: Option<&str>) -> bool { + strategy.is_none_or(|strategy| { + matches!( + strategy, + "Default" | "Eventual" | "Session" | "LatestCommitted" | "GlobalStrong" + ) + }) +} + +fn validate_pipeline_matrix(json: &str, profile: &Profile) -> Result<(), String> { + let document: Value = serde_json::from_str(json).map_err(|error| error.to_string())?; + let matrix = document + .get("matrix") + .and_then(Value::as_object) + .ok_or("pipeline matrix must contain an object named 'matrix'")?; + let actual_profiles = matrix_axis(matrix, "AZURE_COSMOS_E2E_PROFILE")?; + if actual_profiles != BTreeSet::from([profile.id.as_str()]) { + return Err(format!( + "pipeline matrix must select only profile '{}'", + profile.id + )); + } + for (axis, actual, expected) in [ + ( + "account", + matrix_axis(matrix, "AZURE_COSMOS_E2E_ACCOUNT")?, + profile + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect(), ), - comparator => panic!("unsupported string comparator '{comparator}' for {field}"), - } -} - -fn assert_number(expected: &Comparison, actual: Option, field: &str, step: &str) { - let value = expected.value.as_ref().and_then(Value::as_u64); - match expected.comparator.as_str() { - "present" => assert!( - actual.is_some(), - "{field} for step '{step}' must be present" + ( + "runtime", + matrix_axis(matrix, "AZURE_COSMOS_E2E_RUNTIME")?, + profile + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect(), ), - "absent" => assert!(actual.is_none(), "{field} for step '{step}' must be absent"), - "exact" => assert_eq!(actual, value, "diagnostics field {field} for step '{step}'"), - "atLeast" => assert!( - actual.zip(value).is_some_and(|(actual, expected)| actual >= expected), - "diagnostics field {field} for step '{step}' must be at least {value:?}, got {actual:?}" + ( + "client", + matrix_axis(matrix, "AZURE_COSMOS_E2E_CLIENT")?, + profile + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect(), ), - "atMost" => { - assert!( - actual.zip(value).is_some_and(|(actual, expected)| actual <= expected), - "diagnostics field {field} for step '{step}' must be at most {value:?}, got {actual:?}" - ) + ] { + if actual != expected { + return Err(format!( + "pipeline matrix for '{}' does not cover its {axis} axis: expected {expected:?}, got {actual:?}", + profile.id + )); } - comparator => panic!("unsupported numeric comparator '{comparator}' for {field}"), - } -} - -#[cfg(test)] -mod tests { - use super::{ExpectedRead, ExpectedStatus}; - - #[test] - fn status_without_expected_substatus_matches_any_substatus() { - let status = ExpectedStatus { - status_code: 200, - sub_status_code: None, - }; - - assert!(status.matches(200, None)); - assert!(status.matches(200, Some(1002))); - assert!(!status.matches(404, None)); } - - #[test] - fn explicit_zero_substatus_matches_missing_response_substatus() { - let status = ExpectedStatus { - status_code: 404, - sub_status_code: Some(0), - }; - - assert!(status.matches(404, None)); - assert!(status.matches(404, Some(0))); - assert!(!status.matches(404, Some(1002))); + let flavors = matrix_axis(matrix, "AZURE_COSMOS_EMULATOR_FLAVOR")?; + if flavors != BTreeSet::from(["inmemory-v1", "inmemory-v2"]) { + return Err(format!( + "pipeline matrix for '{}' must cover Gateway V1 and Gateway V2", + profile.id + )); } + Ok(()) +} - #[test] - fn status_overlap_accounts_for_wildcard_substatus() { - let wildcard = ExpectedStatus { - status_code: 404, - sub_status_code: None, - }; - let explicit = ExpectedStatus { - status_code: 404, - sub_status_code: Some(1002), - }; - let other_status = ExpectedStatus { - status_code: 200, - sub_status_code: None, - }; +fn matrix_axis<'a>( + matrix: &'a serde_json::Map, + name: &str, +) -> Result, String> { + matrix + .get(name) + .and_then(Value::as_array) + .ok_or_else(|| format!("pipeline matrix is missing '{name}'"))? + .iter() + .map(|value| { + value + .as_str() + .ok_or_else(|| format!("pipeline matrix axis '{name}' must contain strings")) + }) + .collect() +} - assert!(wildcard.overlaps(&explicit)); - assert!(explicit.overlaps(&wildcard)); - assert!(!wildcard.overlaps(&other_status)); - } +fn valid_camel_id(value: &str) -> bool { + value + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value + .chars() + .all(|character| character.is_ascii_alphanumeric()) +} - #[test] - fn read_expectation_distinguishes_transient_and_terminal_statuses() { - let expected = ExpectedRead { - acceptable_initial_statuses: vec![ExpectedStatus { - status_code: 404, - sub_status_code: Some(1002), - }], - terminal_status: ExpectedStatus { - status_code: 200, - sub_status_code: None, - }, - max_wait_ms: Some(5_000), - }; +fn valid_scenario_id(value: &str) -> bool { + let mut segments = value.split('.'); + let first = segments.next(); + let rest: Vec<_> = segments.collect(); + first.is_some_and(valid_slug) && !rest.is_empty() && rest.into_iter().all(valid_slug) +} - assert!(expected.is_acceptable_initial(404, Some(1002))); - assert!(!expected.is_terminal(404, Some(1002))); - assert!(expected.is_terminal(200, None)); - assert!(!expected.is_acceptable_initial(200, None)); - } +fn valid_slug(value: &str) -> bool { + value + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs new file mode 100644 index 00000000000..ba451216dd6 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::options::Region; + +use crate::e2e_test_cases::{ + fixture::{E2eTestFixture, TestResult}, + support::{assert_critical_diagnostics, item, should_run}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn diagnostics_cover_success_and_error() -> TestResult { + if !should_run("diagnostics.success-and-error")? { + return Ok(()); + } + E2eTestFixture::run(async |fixture| { + fixture + .container + .create_item("A", "item-1", item("item-1", "A", 1), None) + .await?; + let success = fixture.container.read_item("A", "item-1", None).await?; + assert_eq!(success.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&success.diagnostics(), "read_item", StatusCode::Ok); + assert!(success + .diagnostics() + .regions_contacted() + .contains(&Region::EAST_US)); + let error = fixture + .container + .read_item("A", "missing", None) + .await + .expect_err("missing read must fail"); + assert_eq!(error.status().status_code(), StatusCode::NotFound); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + let diagnostics = error + .diagnostics() + .expect("service error must carry diagnostics"); + assert_critical_diagnostics(&diagnostics, "read_item", StatusCode::NotFound); + assert!(diagnostics.regions_contacted().contains(&Region::EAST_US)); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs index 3d65a0c5a1c..8b9b4006d7f 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs @@ -5,7 +5,10 @@ use azure_core::Uuid; use azure_data_cosmos::{ clients::ContainerClient, models::{ContainerProperties, PartitionKeyDefinition}, - options::{OperationOptions, ReadConsistencyStrategy, Region}, + options::{ + BinaryEncodingOptions, ConnectionPoolOptions, OperationOptions, PartitionFailoverOptions, + ReadConsistencyStrategy, Region, + }, AccountEndpoint, AccountReference, CosmosClient, CosmosRuntime, RoutingStrategy, }; @@ -86,19 +89,28 @@ pub async fn build_client() -> TestResult { pub async fn build_client_with_routing( routing_strategy: RoutingStrategy, ) -> TestResult { - build_client_with_defaults(routing_strategy, None, None).await + build_client_with_defaults(routing_strategy, None, None, None, None, None).await } pub async fn build_client_with_defaults( routing_strategy: RoutingStrategy, runtime_strategy: Option, client_strategy: Option, + gateway_v2_enabled: Option, + ppcb_enabled: Option, + binary_encoding_enabled: Option, ) -> TestResult { let connection_string = std::env::var("AZURE_COSMOS_CONNECTION_STRING")?; let endpoint = connection_string_value(&connection_string, "AccountEndpoint")?; let key = connection_string_value(&connection_string, "AccountKey")?; let endpoint: AccountEndpoint = endpoint.parse()?; let mut runtime_builder = CosmosRuntime::builder(); + if let Some(enabled) = gateway_v2_enabled { + let options = ConnectionPoolOptions::builder() + .with_gateway_v2_disabled(!enabled) + .build()?; + runtime_builder = runtime_builder.with_connection_pool(options); + } if let Some(strategy) = runtime_strategy { let mut options = OperationOptions::default(); options.read_consistency_strategy = Some(strategy); @@ -112,6 +124,16 @@ pub async fn build_client_with_defaults( options.read_consistency_strategy = Some(strategy); client_builder = client_builder.with_default_operation_options(options); } + if let Some(enabled) = ppcb_enabled { + let options = PartitionFailoverOptions::builder() + .with_circuit_breaker_enabled(enabled) + .build()?; + client_builder = client_builder.with_partition_failover_options(options); + } + if let Some(enabled) = binary_encoding_enabled { + client_builder = client_builder + .with_binary_encoding_options(BinaryEncodingOptions::new().with_enabled(enabled)); + } Ok(client_builder .build( AccountReference::with_authentication_key(endpoint, key), diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs new file mode 100644 index 00000000000..68c75d48203 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::{ + models::{PartitionKeyDefinition, PartitionKeyKind, PartitionKeyValue, PartitionKeyVersion}, + PartitionKey, +}; +use serde_json::{json, Value}; + +use crate::e2e_test_cases::{ + fixture::{E2eTestFixture, TestResult}, + support::{assert_critical_diagnostics, should_run}, +}; + +struct DuplicateCreateCase { + id: &'static str, + partition_key_definition: PartitionKeyDefinition, + partition_key: PartitionKey, + original: Value, + duplicate: Value, +} + +fn duplicate_create_cases() -> Vec { + let simple = |id, version| DuplicateCreateCase { + id, + partition_key_definition: PartitionKeyDefinition::new(vec!["/pk".into()]) + .with_kind(PartitionKeyKind::Hash) + .with_version(version), + partition_key: PartitionKey::from("A"), + original: json!({ "id": "duplicate-1", "pk": "A", "value": 1 }), + duplicate: json!({ "id": "duplicate-1", "pk": "A", "value": 2 }), + }; + vec![ + simple("hashV1", PartitionKeyVersion::V1), + simple("hashV2", PartitionKeyVersion::V2), + DuplicateCreateCase { + id: "hierarchicalV2", + partition_key_definition: PartitionKeyDefinition::new(vec![ + "/tenant".into(), + "/user".into(), + ]) + .with_kind(PartitionKeyKind::MultiHash) + .with_version(PartitionKeyVersion::V2), + partition_key: PartitionKey::from(vec![ + PartitionKeyValue::from("tenant-a"), + PartitionKeyValue::from("user-1"), + ]), + original: json!({ + "id": "duplicate-1", + "tenant": "tenant-a", + "user": "user-1", + "value": 1 + }), + duplicate: json!({ + "id": "duplicate-1", + "tenant": "tenant-a", + "user": "user-1", + "value": 2 + }), + }, + ] +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn duplicate_create_preserves_original() -> TestResult { + if !should_run("item.create-conflict")? { + return Ok(()); + } + for case in duplicate_create_cases() { + let document_id = case + .original + .get("id") + .and_then(Value::as_str) + .expect("original document must have an id"); + assert_eq!( + case.duplicate.get("id").and_then(Value::as_str), + Some(document_id) + ); + + E2eTestFixture::run_with_partition_key(case.partition_key_definition, async |fixture| { + fixture + .container + .create_item( + case.partition_key.clone(), + document_id, + &case.original, + None, + ) + .await?; + let error = fixture + .container + .create_item( + case.partition_key.clone(), + document_id, + &case.duplicate, + None, + ) + .await + .expect_err("duplicate create must fail"); + assert_eq!(error.status().status_code(), StatusCode::Conflict); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + assert_critical_diagnostics( + &error + .diagnostics() + .expect("service error must carry diagnostics"), + "create_item", + StatusCode::Conflict, + ); + let stored: Value = fixture + .container + .read_item(case.partition_key.clone(), document_id, None) + .await? + .into_model()?; + for (name, expected) in case + .original + .as_object() + .expect("original document must be an object") + { + assert_eq!( + stored.get(name), + Some(expected), + "fixture '{}' field '{name}' changed after duplicate create", + case.id + ); + } + Ok(()) + }) + .await?; + } + Ok(()) +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs new file mode 100644 index 00000000000..4e3c036084b --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -0,0 +1,662 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::{ + options::{ + AvailabilityStrategy, ItemReadOptions, OperationOptions, ReadConsistencyStrategy, Region, + }, + RoutingStrategy, +}; + +use crate::e2e_test_cases::{ + catalog::{selected_profile_for, AccountDefinition, ClientDefinition, Profile}, + fixture::{build_client_with_defaults, E2eTestFixture, TestResult}, + support::{assert_critical_diagnostics, item, write_options_with_content, Item}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ExpectedStatus { + status_code: u16, + sub_status_code: Option, +} + +impl ExpectedStatus { + const fn new(status_code: u16, sub_status_code: Option) -> Self { + Self { + status_code, + sub_status_code, + } + } + + fn matches(self, status_code: u16, sub_status_code: Option) -> bool { + self.status_code == status_code + && self + .sub_status_code + .is_none_or(|expected| expected == sub_status_code.unwrap_or(0)) + } +} + +const PLAIN_NOT_FOUND: ExpectedStatus = ExpectedStatus::new(404, Some(0)); +const SESSION_NOT_AVAILABLE: ExpectedStatus = ExpectedStatus::new(404, Some(1002)); +const READ_SUCCEEDED: ExpectedStatus = ExpectedStatus::new(200, None); +const CLIENT_REJECTED: ExpectedStatus = ExpectedStatus::new(400, None); + +#[derive(Debug)] +struct LifecycleReadCase { + id: String, + operation_strategy: Option<&'static str>, + explicit_session_token: bool, + acceptable_initial_statuses: &'static [ExpectedStatus], + terminal_status: ExpectedStatus, + max_wait_ms: Option, +} + +impl LifecycleReadCase { + fn succeeds( + id: impl Into, + operation_strategy: Option<&'static str>, + explicit_session_token: bool, + acceptable_initial_statuses: &'static [ExpectedStatus], + ) -> Self { + Self { + id: id.into(), + operation_strategy, + explicit_session_token, + acceptable_initial_statuses, + terminal_status: READ_SUCCEEDED, + max_wait_ms: (!acceptable_initial_statuses.is_empty()).then_some(5_000), + } + } + + fn rejects(id: impl Into, operation_strategy: &'static str) -> Self { + Self { + id: id.into(), + operation_strategy: Some(operation_strategy), + explicit_session_token: false, + acceptable_initial_statuses: &[], + terminal_status: CLIENT_REJECTED, + max_wait_ms: None, + } + } + + fn is_terminal(&self, status_code: u16, sub_status_code: Option) -> bool { + self.terminal_status.matches(status_code, sub_status_code) + } + + fn is_acceptable_initial(&self, status_code: u16, sub_status_code: Option) -> bool { + self.acceptable_initial_statuses + .iter() + .any(|expected| expected.matches(status_code, sub_status_code)) + } +} + +fn lifecycle_cases( + profile: &Profile, + account: &AccountDefinition, + runtime_default: Option<&str>, + client_default: Option<&str>, +) -> TestResult> { + match profile.id.as_str() { + "hostedEmulatorSmoke" => Ok(vec![LifecycleReadCase::succeeds( + "hostedSmokeDefault", + Some("Default"), + false, + &[], + )]), + "lifecycleConsistencyMatrix" => Ok(consistency_cases(account)), + "readConsistencyOverrideMatrix" => Ok(override_cases(runtime_default, client_default)), + profile => Err(format!("item.lifecycle does not implement profile '{profile}'").into()), + } +} + +fn consistency_cases(account: &AccountDefinition) -> Vec { + let strong = account.consistency == "strong"; + let account_session = account.consistency == "session"; + [ + ("Default", account_session, false), + ("Eventual", false, false), + ("Session", true, true), + ("LatestCommitted", false, false), + ("GlobalStrong", false, false), + ] + .into_iter() + .map(|(strategy, session_read, explicit_session_token)| { + let id = format!("{}/{}", account.id, strategy); + if strategy == "GlobalStrong" && !strong { + LifecycleReadCase::rejects(id, strategy) + } else if strong { + LifecycleReadCase::succeeds(id, Some(strategy), explicit_session_token, &[]) + } else if session_read { + LifecycleReadCase::succeeds( + id, + Some(strategy), + explicit_session_token, + &[SESSION_NOT_AVAILABLE], + ) + } else { + LifecycleReadCase::succeeds( + id, + Some(strategy), + explicit_session_token, + &[PLAIN_NOT_FOUND], + ) + } + }) + .collect() +} + +fn override_cases( + runtime_default: Option<&str>, + client_default: Option<&str>, +) -> Vec { + let inherited_session = client_default + .or(runtime_default) + .is_none_or(|strategy| strategy == "Session"); + let inherited_statuses: &'static [ExpectedStatus] = if inherited_session { + &[SESSION_NOT_AVAILABLE] + } else { + &[PLAIN_NOT_FOUND] + }; + vec![ + LifecycleReadCase::succeeds("override/Inherit", None, false, inherited_statuses), + LifecycleReadCase::succeeds( + "override/Default", + Some("Default"), + true, + &[SESSION_NOT_AVAILABLE], + ), + LifecycleReadCase::succeeds( + "override/Eventual", + Some("Eventual"), + false, + &[PLAIN_NOT_FOUND], + ), + ] +} + +fn lifecycle_read_region(profile: &Profile) -> TestResult { + match profile.id.as_str() { + "hostedEmulatorSmoke" => Ok(Region::EAST_US), + "lifecycleConsistencyMatrix" | "readConsistencyOverrideMatrix" => Ok(Region::WEST_US), + profile => { + Err(format!("item.lifecycle does not define a read region for '{profile}'").into()) + } + } +} + +fn lifecycle_routing( + client: &ClientDefinition, + read_region: &Region, +) -> TestResult { + match client.routing.as_str() { + "proximity" => Ok(RoutingStrategy::ProximityTo(read_region.clone())), + "preferredRegions" => { + let mut regions = vec![read_region.clone()]; + if read_region != &Region::EAST_US { + regions.push(Region::EAST_US); + } + Ok(RoutingStrategy::PreferredRegions(regions)) + } + "accountOrder" => Ok(RoutingStrategy::PreferredRegions(Vec::new())), + routing => Err(format!("unsupported lifecycle routing strategy '{routing}'").into()), + } +} + +fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> TestResult<&'a str> { + match std::env::var(environment_variable) { + Ok(selected) => available + .iter() + .copied() + .find(|candidate| *candidate == selected) + .ok_or_else(|| { + format!("{environment_variable}='{selected}' is not one of {available:?}").into() + }), + Err(_) if available.len() == 1 => Ok(available[0]), + Err(_) => Err(format!( + "{environment_variable} is required because this profile defines {available:?}" + ) + .into()), + } +} + +fn parse_optional_strategy(value: Option<&str>) -> TestResult> { + value + .map(str::parse::) + .transpose() + .map_err(Into::into) +} + +fn parse_setup_switch(value: &str, default: &str) -> TestResult> { + match value { + "enabled" => Ok(Some(true)), + "disabled" => Ok(Some(false)), + value if value == default => Ok(None), + value => Err(format!("unsupported setup switch '{value}'").into()), + } +} + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn item_lifecycle() -> TestResult { + let Some(profile) = selected_profile_for("item.lifecycle")? else { + return Ok(()); + }; + run_lifecycle_consistency_matrix(&profile).await +} + +async fn run_lifecycle_consistency_matrix(profile: &Profile) -> TestResult { + let account_ids: Vec<_> = profile + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let runtime_ids: Vec<_> = profile + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let client_ids: Vec<_> = profile + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let account_id = selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &account_ids)?; + let runtime_id = selected_axis("AZURE_COSMOS_E2E_RUNTIME", &runtime_ids)?; + let client_id = selected_axis("AZURE_COSMOS_E2E_CLIENT", &client_ids)?; + let account = profile.account(account_id); + let runtime = profile.runtime(runtime_id); + let client_definition = profile.client(client_id); + let runtime_strategy = + parse_optional_strategy(runtime.default_read_consistency_strategy.as_deref())?; + let client_strategy = parse_optional_strategy( + client_definition + .default_read_consistency_strategy + .as_deref(), + )?; + let cases = lifecycle_cases( + profile, + account, + runtime.default_read_consistency_strategy.as_deref(), + client_definition + .default_read_consistency_strategy + .as_deref(), + )?; + let read_region = lifecycle_read_region(profile)?; + let routing = lifecycle_routing(client_definition, &read_region)?; + let gateway_v2_enabled = parse_setup_switch(&runtime.gateway_v2, "backendDefault")?; + let ppcb_enabled = parse_setup_switch(&runtime.ppcb, "sdkDefault")?; + let binary_encoding_enabled = + parse_setup_switch(&client_definition.binary_encoding, "sdkDefault")?; + + for case in cases { + let client = build_client_with_defaults( + routing.clone(), + runtime_strategy, + client_strategy, + gateway_v2_enabled, + ppcb_enabled, + binary_encoding_enabled, + ) + .await?; + + E2eTestFixture::run_with_client(client, "/pk".into(), async |fixture| { + let item_id = format!("lifecycle-{}", case.id.replace('/', "-")); + let created = fixture + .container + .create_item("A", &item_id, item(&item_id, "A", 1), None) + .await?; + assert_eq!(created.status().status_code(), StatusCode::Created); + assert_critical_diagnostics( + &created.diagnostics(), + "create_item", + StatusCode::Created, + ); + let create_token = created.headers().session_token().cloned(); + + let mut operation = OperationOptions::default(); + operation.read_consistency_strategy = parse_optional_strategy(case.operation_strategy)?; + operation.availability_strategy = Some(AvailabilityStrategy::Disabled); + let mut read_options = ItemReadOptions::default().with_operation_options(operation); + let mut terminal_item = None; + if case.explicit_session_token { + read_options = read_options.with_session_token( + create_token + .clone() + .ok_or("create response must carry a session token")?, + ); + } + + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_millis(case.max_wait_ms.unwrap_or_default()); + let mut observed_statuses = Vec::new(); + loop { + let (status_code, sub_status_code, terminal) = match fixture + .container + .read_item("A", &item_id, Some(read_options.clone())) + .await + { + Ok(read) => { + let status_code = u16::from(read.status().status_code()); + let sub_status_code = + read.status().sub_status().map(|value| value.value()); + let terminal = case.is_terminal(status_code, sub_status_code); + for request in read.diagnostics().requests().iter() { + let request_status = u16::from(request.status().status_code()); + let request_sub_status = + request.status().sub_status().map(|value| value.value()); + if case.is_acceptable_initial(request_status, request_sub_status) { + observed_statuses + .push((request_status, request_sub_status.unwrap_or(0))); + } + } + if terminal { + assert_critical_diagnostics( + &read.diagnostics(), + "read_item", + StatusCode::Ok, + ); + terminal_item = Some(read.into_model::()?); + } + (status_code, sub_status_code, terminal) + } + Err(error) => { + let status_code = u16::from(error.status().status_code()); + let sub_status_code = + error.status().sub_status().map(|value| value.value()); + let terminal = case.is_terminal(status_code, sub_status_code); + if let Some(diagnostics) = error.diagnostics() { + if terminal && case.terminal_status == CLIENT_REJECTED { + assert_eq!( + diagnostics.request_count(), + 0, + "client validation must reject '{}' before transport", + case.id + ); + } + for request in diagnostics.requests().iter() { + let request_status = u16::from(request.status().status_code()); + let request_sub_status = + request.status().sub_status().map(|value| value.value()); + if case.is_acceptable_initial(request_status, request_sub_status) { + observed_statuses + .push((request_status, request_sub_status.unwrap_or(0))); + } + } + } + (status_code, sub_status_code, terminal) + } + }; + observed_statuses.push((status_code, sub_status_code.unwrap_or(0))); + if terminal { + break; + } + if !case.is_acceptable_initial(status_code, sub_status_code) { + return Err(format!( + "execution '{}' observed unexpected read status {status_code}/{}; expected transient {:?} or terminal {:?}; observed {observed_statuses:?}", + case.id, + sub_status_code.unwrap_or(0), + case.acceptable_initial_statuses, + case.terminal_status, + ) + .into()); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "execution '{}' did not reach terminal status {:?} within {} ms; observed {observed_statuses:?}", + case.id, + case.terminal_status, + case.max_wait_ms.unwrap_or_default(), + ) + .into()); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + if case.terminal_status == READ_SUCCEEDED { + assert_eq!( + terminal_item, + Some(item(&item_id, "A", 1)), + "terminal read for '{}' returned the wrong item", + case.id + ); + } else { + assert!(terminal_item.is_none()); + } + let replaced = fixture + .container + .replace_item( + "A", + &item_id, + item(&item_id, "A", 2), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics( + &replaced.diagnostics(), + "replace_item", + StatusCode::Ok, + ); + assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); + let deleted = fixture.container.delete_item("A", &item_id, None).await?; + assert_eq!(deleted.status().status_code(), StatusCode::NoContent); + assert_critical_diagnostics( + &deleted.diagnostics(), + "delete_item", + StatusCode::NoContent, + ); + let delete_token = deleted + .headers() + .session_token() + .cloned() + .ok_or("delete response must carry a session token")?; + let mut delete_read_operation = OperationOptions::default(); + delete_read_operation.read_consistency_strategy = + Some(ReadConsistencyStrategy::Session); + delete_read_operation.availability_strategy = Some(AvailabilityStrategy::Disabled); + let delete_read_options = ItemReadOptions::default() + .with_operation_options(delete_read_operation) + .with_session_token(delete_token); + + let delete_deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match fixture + .container + .read_item("A", &item_id, Some(delete_read_options.clone())) + .await + { + Err(error) + if PLAIN_NOT_FOUND.matches( + u16::from(error.status().status_code()), + error.status().sub_status().map(|value| value.value()), + ) => + { + break; + } + Err(error) + if SESSION_NOT_AVAILABLE.matches( + u16::from(error.status().status_code()), + error.status().sub_status().map(|value| value.value()), + ) && tokio::time::Instant::now() < delete_deadline => + { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Ok(_) if tokio::time::Instant::now() < delete_deadline => { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Ok(_) => { + return Err(format!( + "deleted item for '{}' remained visible after 5 seconds", + case.id + ) + .into()) + } + Err(error) => return Err(error.into()), + } + } + Ok(()) + }) + .await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + consistency_cases, override_cases, ExpectedStatus, CLIENT_REJECTED, PLAIN_NOT_FOUND, + READ_SUCCEEDED, SESSION_NOT_AVAILABLE, + }; + + #[test] + fn status_matching_normalizes_missing_substatus_to_zero() { + assert!(PLAIN_NOT_FOUND.matches(404, None)); + assert!(PLAIN_NOT_FOUND.matches(404, Some(0))); + assert!(!PLAIN_NOT_FOUND.matches(404, Some(1002))); + assert!(READ_SUCCEEDED.matches(200, Some(0))); + assert!(!ExpectedStatus::new(200, None).matches(404, None)); + } + + #[test] + fn account_consistency_matrix_covers_all_operation_strategies() { + let profile = serde_json::from_str::(include_str!( + "../../../e2e_tests/profiles/lifecycleConsistencyMatrix.json" + )) + .expect("consistency profile must deserialize"); + let expectations = [ + ("strong", [None, None, None, None, None]), + ( + "boundedStaleness", + [ + Some(PLAIN_NOT_FOUND), + Some(PLAIN_NOT_FOUND), + Some(SESSION_NOT_AVAILABLE), + Some(PLAIN_NOT_FOUND), + None, + ], + ), + ( + "session", + [ + Some(SESSION_NOT_AVAILABLE), + Some(PLAIN_NOT_FOUND), + Some(SESSION_NOT_AVAILABLE), + Some(PLAIN_NOT_FOUND), + None, + ], + ), + ( + "consistentPrefix", + [ + Some(PLAIN_NOT_FOUND), + Some(PLAIN_NOT_FOUND), + Some(SESSION_NOT_AVAILABLE), + Some(PLAIN_NOT_FOUND), + None, + ], + ), + ( + "eventual", + [ + Some(PLAIN_NOT_FOUND), + Some(PLAIN_NOT_FOUND), + Some(SESSION_NOT_AVAILABLE), + Some(PLAIN_NOT_FOUND), + None, + ], + ), + ]; + + for (account, transient_statuses) in expectations { + let cases = consistency_cases(profile.account(account)); + assert_eq!( + cases + .iter() + .map(|case| case.operation_strategy) + .collect::>(), + [ + Some("Default"), + Some("Eventual"), + Some("Session"), + Some("LatestCommitted"), + Some("GlobalStrong"), + ] + ); + assert_eq!( + cases + .iter() + .map(|case| case.explicit_session_token) + .collect::>(), + [false, false, true, false, false] + ); + for (index, expected_transient) in transient_statuses.into_iter().enumerate() { + assert_eq!( + cases[index].acceptable_initial_statuses, + expected_transient.as_slice() + ); + let terminal = if index == 4 && account != "strong" { + CLIENT_REJECTED + } else { + READ_SUCCEEDED + }; + assert_eq!(cases[index].terminal_status, terminal); + assert_eq!(cases[index].max_wait_ms, expected_transient.map(|_| 5_000)); + } + } + } + + #[test] + fn override_matrix_keeps_operation_cases_in_source() { + let profile = serde_json::from_str::(include_str!( + "../../../e2e_tests/profiles/readConsistencyOverrideMatrix.json" + )) + .expect("override profile must deserialize"); + + for runtime in &profile.runtimes { + for client in &profile.clients { + let cases = override_cases( + runtime.default_read_consistency_strategy.as_deref(), + client.default_read_consistency_strategy.as_deref(), + ); + assert_eq!( + cases + .iter() + .map(|case| case.operation_strategy) + .collect::>(), + [None, Some("Default"), Some("Eventual")] + ); + assert_eq!( + cases + .iter() + .map(|case| case.explicit_session_token) + .collect::>(), + [false, true, false] + ); + let inherited = if client + .default_read_consistency_strategy + .as_deref() + .or(runtime.default_read_consistency_strategy.as_deref()) + .is_none_or(|strategy| strategy == "Session") + { + SESSION_NOT_AVAILABLE + } else { + PLAIN_NOT_FOUND + }; + assert_eq!(cases[0].acceptable_initial_statuses, [inherited]); + assert_eq!( + cases[1].acceptable_initial_statuses, + [SESSION_NOT_AVAILABLE] + ); + assert_eq!(cases[2].acceptable_initial_statuses, [PLAIN_NOT_FOUND]); + assert!(cases.iter().all(|case| { + case.terminal_status == READ_SUCCEEDED && case.max_wait_ms == Some(5_000) + })); + } + } + } +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs new file mode 100644 index 00000000000..776798256a7 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; + +use crate::e2e_test_cases::{ + fixture::{E2eTestFixture, TestResult}, + support::{assert_critical_diagnostics, item, should_run, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn not_found_does_not_cross_partition_keys() -> TestResult { + if !should_run("item.not-found-wrong-partition-key")? { + return Ok(()); + } + E2eTestFixture::run(async |fixture| { + fixture + .container + .create_item("A", "item-1", item("item-1", "A", 1), None) + .await?; + for (id, pk) in [("missing", "A"), ("item-1", "B")] { + let error = fixture + .container + .read_item(pk, id, None) + .await + .expect_err("read must return not found"); + assert_eq!(error.status().status_code(), StatusCode::NotFound); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + assert_critical_diagnostics( + &error + .diagnostics() + .expect("service error must carry diagnostics"), + "read_item", + StatusCode::NotFound, + ); + } + let read = fixture.container.read_item("A", "item-1", None).await?; + assert_eq!(read.into_model::()?.value, 1); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs new file mode 100644 index 00000000000..aba42fe306b --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::{Etag, StatusCode}; +use azure_data_cosmos::options::{ItemWriteOptions, Precondition}; + +use crate::e2e_test_cases::{ + fixture::{E2eTestFixture, TestResult}, + support::{assert_critical_diagnostics, item, should_run, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn stale_etag_preserves_successful_update() -> TestResult { + if !should_run("item.optimistic-concurrency")? { + return Ok(()); + } + E2eTestFixture::run(async |fixture| { + let created = fixture + .container + .create_item("A", "etag-1", item("etag-1", "A", 1), None) + .await?; + let initial_etag = created + .headers() + .etag() + .expect("create must return an ETag") + .clone(); + let current_options = ItemWriteOptions::default() + .with_precondition(Precondition::IfMatch(initial_etag.clone())); + let replaced = fixture + .container + .replace_item("A", "etag-1", item("etag-1", "A", 2), Some(current_options)) + .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); + let stale_options = ItemWriteOptions::default() + .with_precondition(Precondition::IfMatch(Etag::from(initial_etag.to_string()))); + let error = fixture + .container + .replace_item("A", "etag-1", item("etag-1", "A", 3), Some(stale_options)) + .await + .expect_err("stale ETag must fail"); + assert_eq!(error.status().status_code(), StatusCode::PreconditionFailed); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + assert_critical_diagnostics( + &error + .diagnostics() + .expect("service error must carry diagnostics"), + "replace_item", + StatusCode::PreconditionFailed, + ); + assert_eq!( + fixture + .container + .read_item("A", "etag-1", None) + .await? + .into_model::()? + .value, + 2 + ); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs new file mode 100644 index 00000000000..f17c12bb90c --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::{feed::FeedScope, Query}; +use futures::TryStreamExt; + +use crate::e2e_test_cases::{ + fixture::{E2eTestFixture, TestResult}, + support::{assert_critical_diagnostics, item, should_run, write_options_with_content, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn upsert_creates_then_updates() -> TestResult { + if !should_run("item.upsert-create-update")? { + return Ok(()); + } + E2eTestFixture::run(async |fixture| { + let created = fixture + .container + .upsert_item( + "A", + "upsert-1", + item("upsert-1", "A", 1), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(created.status().status_code(), StatusCode::Created); + assert_critical_diagnostics(&created.diagnostics(), "upsert_item", StatusCode::Created); + let updated = fixture + .container + .upsert_item( + "A", + "upsert-1", + item("upsert-1", "A", 2), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(updated.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&updated.diagnostics(), "upsert_item", StatusCode::Ok); + assert_eq!(updated.into_model::()?.value, 2); + let items: Vec = fixture + .container + .query_items( + Query::from("SELECT * FROM c WHERE c.id = @id") + .with_parameter("@id", "upsert-1")?, + FeedScope::partition("A"), + None, + ) + .await? + .try_collect() + .await?; + assert_eq!(items, [item("upsert-1", "A", 2)]); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs index 18dfea95497..c116ee9721b 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs @@ -1,695 +1,35 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +mod bootstrap_primary; +mod capabilities; mod catalog; +mod diagnostics_success_and_error; mod fixture; - -use azure_core::http::{Etag, StatusCode}; -use azure_data_cosmos::{ - feed::FeedScope, - options::{ - AvailabilityStrategy, ItemReadOptions, ItemWriteOptions, OperationOptions, Precondition, - ReadConsistencyStrategy, Region, - }, - Query, RoutingStrategy, -}; -use futures::{StreamExt, TryStreamExt}; -use serde::{Deserialize, Serialize}; - -use catalog::{assert_diagnostics, assert_status, profile as load_profile, scenario}; -use fixture::{build_client, build_client_with_defaults, E2eTestFixture, TestResult}; +mod item_create_conflict; +mod item_not_found; +mod item_optimistic_concurrency; +mod item_upsert; +#[path = "item_lifecycle.rs"] +mod lifecycle; +mod query_invalid_syntax; +mod query_parameterized_filter; +mod support; const IMPLEMENTED_TESTS: &[&str] = &[ - "capability_document_is_versioned", - "bootstrap_primary_endpoint", - "item_lifecycle", - "upsert_creates_then_updates", - "duplicate_create_preserves_original", - "not_found_does_not_cross_partition_keys", - "stale_etag_preserves_successful_update", - "parameterized_query_filters_and_orders", - "invalid_query_is_not_an_empty_feed", - "diagnostics_cover_success_and_error", + "capabilities::capability_document_is_versioned", + "bootstrap_primary::bootstrap_primary_endpoint", + "lifecycle::item_lifecycle", + "item_upsert::upsert_creates_then_updates", + "item_create_conflict::duplicate_create_preserves_original", + "item_not_found::not_found_does_not_cross_partition_keys", + "item_optimistic_concurrency::stale_etag_preserves_successful_update", + "query_parameterized_filter::parameterized_query_filters_and_orders", + "query_invalid_syntax::invalid_query_is_not_an_empty_feed", + "diagnostics_success_and_error::diagnostics_cover_success_and_error", ]; -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -struct Item { - id: String, - pk: String, - value: i64, - #[serde(default, skip_serializing_if = "Option::is_none")] - score: Option, -} - -fn item(id: &str, pk: &str, value: i64) -> Item { - Item { - id: id.to_owned(), - pk: pk.to_owned(), - value, - score: None, - } -} - -fn write_options_with_content() -> ItemWriteOptions { - let mut operation = OperationOptions::default(); - operation.content_response_on_write = - Some(azure_data_cosmos::options::ContentResponseOnWrite::Enabled); - ItemWriteOptions::default().with_operation_options(operation) -} - -fn hosted_only() -> bool { - cfg!(any( - test_category = "emulator_inmemory", - test_category = "e2e" - )) -} - #[test] fn e2e_scenario_catalog_is_valid() { catalog::validate_catalog(IMPLEMENTED_TESTS).expect("E2E scenario catalog must be valid"); } - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CapabilityDocument { - api_version: u32, - emulator_version: String, - protocols: ProtocolCapabilities, - data_plane: Vec, - management_actions: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProtocolCapabilities { - gateway_v1: bool, - gateway_v2: bool, -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn capability_document_is_versioned() -> TestResult { - let test_scenario = scenario("management.capabilities"); - let step = test_scenario.step("readCapabilities"); - let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; - let response = reqwest::Client::new() - .get(url::Url::parse(&management_endpoint)?.join("capabilities")?) - .send() - .await?; - assert_status(step, response.status().as_u16(), None); - let capabilities: CapabilityDocument = serde_json::from_slice(&response.bytes().await?)?; - assert_eq!(capabilities.api_version, 1); - assert!(!capabilities.emulator_version.is_empty()); - assert!(capabilities.protocols.gateway_v1); - assert!(capabilities.data_plane.iter().any(|value| value == "item")); - assert!(capabilities - .management_actions - .iter() - .any(|value| value == "partitionSplit")); - let expects_v2 = std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR").as_deref() == Ok("inmemory-v2"); - assert_eq!(capabilities.protocols.gateway_v2, expects_v2); - Ok(()) -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn bootstrap_primary_endpoint() -> TestResult { - let test_scenario = scenario("bootstrap.primary-success"); - let step = test_scenario.step("buildClient"); - let client = build_client().await?; - assert!(hosted_only()); - let database_id = format!("e2e-bootstrap-{}", azure_core::Uuid::new_v4()); - let response = client.create_database(&database_id, None).await?; - assert_status( - step, - u16::from(response.status().status_code()), - response.status().sub_status().map(|value| value.value()), - ); - assert!(response.status().status_code().is_success()); - response.into_model()?; - client.database_client(&database_id).delete(None).await?; - Ok(()) -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn item_lifecycle() -> TestResult { - let profile = std::env::var("AZURE_COSMOS_E2E_PROFILE") - .unwrap_or_else(|_| "hostedEmulatorSmoke".to_owned()); - run_lifecycle_consistency_matrix(&profile).await -} - -async fn run_lifecycle_consistency_matrix(profile: &str) -> TestResult { - let test_scenario = scenario("item.lifecycle"); - let profile_definition = load_profile(profile); - let account_ids: Vec<_> = profile_definition - .accounts - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let runtime_ids: Vec<_> = profile_definition - .runtimes - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let client_ids: Vec<_> = profile_definition - .clients - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let account_id = selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &account_ids)?; - let runtime_id = selected_axis("AZURE_COSMOS_E2E_RUNTIME", &runtime_ids)?; - let client_id = selected_axis("AZURE_COSMOS_E2E_CLIENT", &client_ids)?; - let executions: Vec<_> = test_scenario - .executions_for_configuration(profile, account_id, runtime_id, client_id) - .collect(); - if executions.is_empty() { - return Err(format!( - "item.lifecycle has no execution for profile='{profile}', account='{account_id}', runtime='{runtime_id}', client='{client_id}'" - ) - .into()); - } - - let runtime_strategy = parse_optional_strategy( - profile_definition - .runtime(runtime_id) - .default_read_consistency_strategy - .as_deref(), - )?; - let client_strategy = parse_optional_strategy( - profile_definition - .client(client_id) - .default_read_consistency_strategy - .as_deref(), - )?; - - for execution in executions { - let read_region = match execution.read_region.as_str() { - "East US" => Region::EAST_US, - "West US" => Region::WEST_US, - region => return Err(format!("unsupported lifecycle read region '{region}'").into()), - }; - let client = build_client_with_defaults( - RoutingStrategy::PreferredRegions(vec![read_region.clone(), Region::EAST_US]), - runtime_strategy, - client_strategy, - ) - .await?; - let definition = test_scenario.fixture("hashV2").partition_key_definition()?; - - E2eTestFixture::run_with_client(client, definition, async |fixture| { - let item_id = format!("lifecycle-{}", execution.id); - let created = fixture - .container - .create_item("A", &item_id, item(&item_id, "A", 1), None) - .await?; - assert_eq!(created.status().status_code(), StatusCode::Created); - let create_token = created.headers().session_token().cloned(); - - let mut operation = OperationOptions::default(); - operation.read_consistency_strategy = parse_optional_strategy( - (execution.read_consistency_strategy != "Inherit") - .then_some(execution.read_consistency_strategy.as_str()), - )?; - operation.availability_strategy = Some(AvailabilityStrategy::Disabled); - let mut read_options = ItemReadOptions::default().with_operation_options(operation); - if execution.session_token == "createResponse" { - read_options = read_options.with_session_token( - create_token - .clone() - .ok_or("create response must carry a session token")?, - ); - } - - let deadline = tokio::time::Instant::now() - + std::time::Duration::from_millis( - execution.expected_read.max_wait_ms.unwrap_or_default(), - ); - let mut observed_statuses = Vec::new(); - loop { - let (status_code, sub_status_code, terminal) = match fixture - .container - .read_item("A", &item_id, Some(read_options.clone())) - .await - { - Ok(read) => { - let status_code = u16::from(read.status().status_code()); - let sub_status_code = - read.status().sub_status().map(|value| value.value()); - for request in read.diagnostics().requests().iter() { - let request_status = u16::from(request.status().status_code()); - let request_sub_status = - request.status().sub_status().map(|value| value.value()); - if execution - .expected_read - .is_acceptable_initial(request_status, request_sub_status) - { - observed_statuses - .push((request_status, request_sub_status.unwrap_or(0))); - } - } - ( - status_code, - sub_status_code, - execution - .expected_read - .is_terminal(status_code, sub_status_code), - ) - } - Err(error) => { - let status_code = u16::from(error.status().status_code()); - let sub_status_code = - error.status().sub_status().map(|value| value.value()); - if let Some(diagnostics) = error.diagnostics() { - for request in diagnostics.requests().iter() { - let request_status = u16::from(request.status().status_code()); - let request_sub_status = - request.status().sub_status().map(|value| value.value()); - if execution - .expected_read - .is_acceptable_initial(request_status, request_sub_status) - { - observed_statuses - .push((request_status, request_sub_status.unwrap_or(0))); - } - } - } - ( - status_code, - sub_status_code, - execution - .expected_read - .is_terminal(status_code, sub_status_code), - ) - } - }; - observed_statuses.push((status_code, sub_status_code.unwrap_or(0))); - if terminal { - break; - } - if !execution - .expected_read - .is_acceptable_initial(status_code, sub_status_code) - { - return Err(format!( - "execution '{}' observed unexpected read status {status_code}/{}; expected transient {:?} or terminal {:?}; observed {observed_statuses:?}", - execution.id, - sub_status_code.unwrap_or(0), - execution.expected_read.acceptable_initial_statuses, - execution.expected_read.terminal_status, - ) - .into()); - } - if tokio::time::Instant::now() >= deadline { - return Err(format!( - "execution '{}' did not reach terminal status {:?} within {} ms; observed {observed_statuses:?}", - execution.id, - execution.expected_read.terminal_status, - execution.expected_read.max_wait_ms.unwrap_or_default(), - ) - .into()); - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - - let replaced = fixture - .container - .replace_item("A", &item_id, item(&item_id, "A", 2), None) - .await?; - assert_eq!(replaced.status().status_code(), StatusCode::Ok); - let deleted = fixture.container.delete_item("A", &item_id, None).await?; - assert_eq!(deleted.status().status_code(), StatusCode::NoContent); - Ok(()) - }) - .await?; - } - Ok(()) -} - -fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> TestResult<&'a str> { - match std::env::var(environment_variable) { - Ok(selected) => available - .iter() - .copied() - .find(|candidate| *candidate == selected) - .ok_or_else(|| { - format!("{environment_variable}='{selected}' is not one of {available:?}").into() - }), - Err(_) if available.len() == 1 => Ok(available[0]), - Err(_) => Err(format!( - "{environment_variable} is required because this profile defines {available:?}" - ) - .into()), - } -} - -fn parse_optional_strategy(value: Option<&str>) -> TestResult> { - value - .map(str::parse::) - .transpose() - .map_err(Into::into) -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn upsert_creates_then_updates() -> TestResult { - E2eTestFixture::run(async |fixture| { - let test_scenario = scenario("item.upsert-create-update"); - let created = fixture - .container - .upsert_item( - "A", - "upsert-1", - item("upsert-1", "A", 1), - Some(write_options_with_content()), - ) - .await?; - assert_status( - test_scenario.step("upsertCreate"), - u16::from(created.status().status_code()), - None, - ); - assert_diagnostics(test_scenario.step("upsertCreate"), &created.diagnostics()); - let updated = fixture - .container - .upsert_item( - "A", - "upsert-1", - item("upsert-1", "A", 2), - Some(write_options_with_content()), - ) - .await?; - assert_status( - test_scenario.step("upsertUpdate"), - u16::from(updated.status().status_code()), - None, - ); - assert_diagnostics(test_scenario.step("upsertUpdate"), &updated.diagnostics()); - assert_eq!(updated.into_model::()?.value, 2); - Ok(()) - }) - .await -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn duplicate_create_preserves_original() -> TestResult { - let test_scenario = scenario("item.create-conflict"); - assert_eq!( - test_scenario - .fixtures - .iter() - .map(|fixture| fixture.id.as_str()) - .collect::>(), - ["hashV1", "hashV2", "hierarchicalV2"] - ); - - for scenario_fixture in &test_scenario.fixtures { - let partition_key_definition = scenario_fixture.partition_key_definition()?; - let original = scenario_fixture.item("original"); - let duplicate = scenario_fixture.item("duplicate"); - assert!(original.seed); - assert!(!duplicate.seed); - assert_eq!(original.document_id()?, duplicate.document_id()?); - - E2eTestFixture::run_with_partition_key(partition_key_definition, async |fixture| { - fixture - .container - .create_item( - original.partition_key()?, - original.document_id()?, - &original.document, - None, - ) - .await?; - let error = fixture - .container - .create_item( - duplicate.partition_key()?, - duplicate.document_id()?, - &duplicate.document, - None, - ) - .await - .expect_err("duplicate create must fail"); - let step = test_scenario.step("duplicateCreate"); - assert_status( - step, - u16::from(error.status().status_code()), - error.status().sub_status().map(|value| value.value()), - ); - assert_diagnostics( - step, - &error - .diagnostics() - .expect("service error must carry diagnostics"), - ); - let stored: serde_json::Value = fixture - .container - .read_item(original.partition_key()?, original.document_id()?, None) - .await? - .into_model()?; - for (name, expected) in original - .document - .as_object() - .expect("fixture document must be an object") - { - assert_eq!( - stored.get(name), - Some(expected), - "fixture '{}' field '{name}' changed after duplicate create", - scenario_fixture.id - ); - } - Ok(()) - }) - .await?; - } - Ok(()) -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn not_found_does_not_cross_partition_keys() -> TestResult { - E2eTestFixture::run(async |fixture| { - let test_scenario = scenario("item.not-found-wrong-partition-key"); - fixture - .container - .create_item("A", "item-1", item("item-1", "A", 1), None) - .await?; - for (step_id, id, pk) in [ - ("missing", "missing", "A"), - ("wrongPartitionKey", "item-1", "B"), - ] { - let error = fixture - .container - .read_item(pk, id, None) - .await - .expect_err("read must return not found"); - let step = test_scenario.step(step_id); - assert_status( - step, - u16::from(error.status().status_code()), - error.status().sub_status().map(|value| value.value()), - ); - assert_diagnostics( - step, - &error - .diagnostics() - .expect("service error must carry diagnostics"), - ); - } - let read = fixture.container.read_item("A", "item-1", None).await?; - assert_eq!(read.into_model::()?.value, 1); - Ok(()) - }) - .await -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn stale_etag_preserves_successful_update() -> TestResult { - E2eTestFixture::run(async |fixture| { - let test_scenario = scenario("item.optimistic-concurrency"); - let created = fixture - .container - .create_item("A", "etag-1", item("etag-1", "A", 1), None) - .await?; - let initial_etag = created - .headers() - .etag() - .expect("create must return an ETag") - .clone(); - let current_options = ItemWriteOptions::default() - .with_precondition(Precondition::IfMatch(initial_etag.clone())); - let replaced = fixture - .container - .replace_item("A", "etag-1", item("etag-1", "A", 2), Some(current_options)) - .await?; - assert_status( - test_scenario.step("replaceCurrent"), - u16::from(replaced.status().status_code()), - None, - ); - let stale_options = ItemWriteOptions::default() - .with_precondition(Precondition::IfMatch(Etag::from(initial_etag.to_string()))); - let error = fixture - .container - .replace_item("A", "etag-1", item("etag-1", "A", 3), Some(stale_options)) - .await - .expect_err("stale ETag must fail"); - let step = test_scenario.step("replaceStale"); - assert_status( - step, - u16::from(error.status().status_code()), - error.status().sub_status().map(|value| value.value()), - ); - assert_diagnostics( - step, - &error - .diagnostics() - .expect("service error must carry diagnostics"), - ); - assert_eq!( - fixture - .container - .read_item("A", "etag-1", None) - .await? - .into_model::()? - .value, - 2 - ); - Ok(()) - }) - .await -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn parameterized_query_filters_and_orders() -> TestResult { - E2eTestFixture::run(async |fixture| { - let test_scenario = scenario("query.parameterized-filter"); - for score in 1..=3 { - let id = format!("item-{score}"); - let mut value = item(&id, "A", score); - value.score = Some(score); - fixture.container.create_item("A", &id, value, None).await?; - } - let query = Query::from( - "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score ASC", - ) - .with_parameter("@pk", "A")? - .with_parameter("@min", 2)?; - let mut results = fixture - .container - .query_items::(query, FeedScope::partition("A"), None) - .await?; - let items: Vec = results.by_ref().try_collect().await?; - assert_eq!( - items - .iter() - .map(|item| item.id.as_str()) - .collect::>(), - ["item-2", "item-3"] - ); - assert_eq!(test_scenario.step("query").expected.status, 200); - Ok(()) - }) - .await -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn invalid_query_is_not_an_empty_feed() -> TestResult { - E2eTestFixture::run(async |fixture| { - let test_scenario = scenario("query.invalid-syntax"); - let result = fixture - .container - .query_items::("SELECT FROM", FeedScope::partition("A"), None) - .await; - let error = match result { - Err(error) => error, - Ok(mut stream) => stream - .next() - .await - .expect("invalid query must produce an error") - .expect_err("invalid query must not produce a page"), - }; - assert_status( - test_scenario.step("invalidQuery"), - u16::from(error.status().status_code()), - error.status().sub_status().map(|value| value.value()), - ); - Ok(()) - }) - .await -} - -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn diagnostics_cover_success_and_error() -> TestResult { - E2eTestFixture::run(async |fixture| { - let test_scenario = scenario("diagnostics.success-and-error"); - fixture - .container - .create_item("A", "item-1", item("item-1", "A", 1), None) - .await?; - let success = fixture.container.read_item("A", "item-1", None).await?; - let success_step = test_scenario.step("successfulRead"); - assert_status( - success_step, - u16::from(success.status().status_code()), - None, - ); - assert_diagnostics(success_step, &success.diagnostics()); - let error = fixture - .container - .read_item("A", "missing", None) - .await - .expect_err("missing read must fail"); - let error_step = test_scenario.step("missingRead"); - assert_status( - error_step, - u16::from(error.status().status_code()), - error.status().sub_status().map(|value| value.value()), - ); - assert_diagnostics( - error_step, - &error - .diagnostics() - .expect("service error must carry diagnostics"), - ); - Ok(()) - }) - .await -} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs new file mode 100644 index 00000000000..0d423163e19 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::feed::FeedScope; +use futures::StreamExt; + +use crate::e2e_test_cases::{ + fixture::{E2eTestFixture, TestResult}, + support::{should_run, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn invalid_query_is_not_an_empty_feed() -> TestResult { + if !should_run("query.invalid-syntax")? { + return Ok(()); + } + E2eTestFixture::run(async |fixture| { + let result = fixture + .container + .query_items::("SELECT FROM", FeedScope::partition("A"), None) + .await; + let error = match result { + Err(error) => error, + Ok(mut stream) => stream + .next() + .await + .expect("invalid query must produce an error") + .expect_err("invalid query must not produce a page"), + }; + assert_eq!(error.status().status_code(), StatusCode::BadRequest); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs new file mode 100644 index 00000000000..865ecf18212 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_data_cosmos::{feed::FeedScope, Query}; +use futures::{StreamExt, TryStreamExt}; + +use crate::e2e_test_cases::{ + fixture::{E2eTestFixture, TestResult}, + support::{item, should_run, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn parameterized_query_filters_and_orders() -> TestResult { + if !should_run("query.parameterized-filter")? { + return Ok(()); + } + E2eTestFixture::run(async |fixture| { + for score in 1..=3 { + let id = format!("item-{score}"); + let mut value = item(&id, "A", score); + value.score = Some(score); + fixture.container.create_item("A", &id, value, None).await?; + } + let query = Query::from( + "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score ASC", + ) + .with_parameter("@pk", "A")? + .with_parameter("@min", 2)?; + let mut results = fixture + .container + .query_items::(query, FeedScope::partition("A"), None) + .await?; + let items: Vec = results.by_ref().try_collect().await?; + assert_eq!( + items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["item-2", "item-3"] + ); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs new file mode 100644 index 00000000000..8d8afd1b9e0 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::{ + diagnostics::DiagnosticsContext, + options::{ContentResponseOnWrite, ItemWriteOptions, OperationOptions}, +}; +use serde::{Deserialize, Serialize}; + +use crate::e2e_test_cases::{catalog::selected_profile_for, fixture::TestResult}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(super) struct Item { + pub(super) id: String, + pub(super) pk: String, + pub(super) value: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) score: Option, +} + +pub(super) fn item(id: &str, pk: &str, value: i64) -> Item { + Item { + id: id.to_owned(), + pk: pk.to_owned(), + value, + score: None, + } +} + +pub(super) fn write_options_with_content() -> ItemWriteOptions { + let mut operation = OperationOptions::default(); + operation.content_response_on_write = Some(ContentResponseOnWrite::Enabled); + ItemWriteOptions::default().with_operation_options(operation) +} + +pub(super) fn hosted_only() -> bool { + cfg!(any( + test_category = "emulator_inmemory", + test_category = "e2e" + )) +} + +pub(super) fn should_run(scenario_id: &str) -> TestResult { + let Some(profile) = selected_profile_for(scenario_id)? else { + return Ok(false); + }; + if profile.accounts.len() != 1 || profile.runtimes.len() != 1 || profile.clients.len() != 1 { + return Err(format!( + "scenario '{scenario_id}' requires a single-cell setup profile, got '{}'", + profile.id + ) + .into()); + } + let runtime = &profile.runtimes[0]; + let client = &profile.clients[0]; + if runtime.gateway_v2 != "backendDefault" + || runtime.ppcb != "sdkDefault" + || runtime.default_read_consistency_strategy.is_some() + || client.binary_encoding != "sdkDefault" + || client.routing != "proximity" + || client.default_read_consistency_strategy.is_some() + { + return Err(format!( + "scenario '{scenario_id}' does not implement the runtime/client settings in profile '{}'", + profile.id + ) + .into()); + } + Ok(true) +} + +pub(super) fn assert_critical_diagnostics( + diagnostics: &DiagnosticsContext, + operation_name: &str, + status_code: StatusCode, +) { + assert_eq!(diagnostics.operation_name(), Some(operation_name)); + assert!(!diagnostics.activity_id().to_string().is_empty()); + assert_eq!( + diagnostics + .effective_status() + .map(|status| status.status_code()), + Some(status_code) + ); + assert!(diagnostics.request_count() >= 1); +} diff --git a/sdk/cosmos/e2e_tests/README.md b/sdk/cosmos/e2e_tests/README.md index a5768d760d3..ef3d16ca5f7 100644 --- a/sdk/cosmos/e2e_tests/README.md +++ b/sdk/cosmos/e2e_tests/README.md @@ -1,120 +1,94 @@ -# Cosmos SDK E2E test scenarios +# Cosmos SDK E2E test catalog -This directory contains language-neutral E2E test scenarios for Cosmos DB -SDKs. A scenario describes observable SDK behavior; it does not prescribe a -language-specific API shape or expose driver internals. +This directory contains language-neutral metadata and reusable setup profiles +for Cosmos DB SDK E2E tests. Test behavior remains in each SDK's source code so +it is type checked, directly reviewable, and idiomatic for that language. ## Layout -- `schema/` contains the JSON Schemas for scenarios and profiles. -- `vocabulary/` contains controlled identifiers shared by SDK runners. +- `schema/` contains JSON Schemas for scenario metadata and setup profiles. - `profiles/` contains reusable account, runtime, and client configurations. -- `scenarios/` contains one JSON document per semantic scenario. -- `implementations/` maps scenarios to SDK-specific test implementations. +- `scenarios/` describes the behavior covered by each stable scenario ID. +- `implementations/` maps scenario IDs to SDK-specific test implementations. Scenario IDs are permanent. If an established scenario changes meaning, add a new scenario and deprecate the old one rather than reusing its ID. +## Scenario metadata + +A scenario document intentionally stays concise. It records: + +- a stable ID, title, and normative requirement; +- maturity, tags, and prior service or SDK test precedents; +- the setup profiles under which the implementation should run; +- backend applicability, fidelity, and required capabilities. + +Scenario JSON does not describe executable steps, operation-level options, +fixtures, retries, diagnostics, or assertions. Those concerns vary by SDK and +scenario and belong in the source-native implementation. This prevents the +catalog from becoming a second programming language and lets compiler and IDE +tooling validate the test logic. + +## Setup profiles + +A profile defines three setup axes: `accounts`, `runtimes`, and `clients`. +Pipeline matrices select one profile and one value from each axis through: + +- `AZURE_COSMOS_E2E_PROFILE`; +- `AZURE_COSMOS_E2E_ACCOUNT`; +- `AZURE_COSMOS_E2E_RUNTIME`; +- `AZURE_COSMOS_E2E_CLIENT`. + +The hosted emulator setup converts the selected account definition into an +emulator configuration. SDK fixtures apply the selected runtime and client +configuration. A test runs only when its scenario metadata includes the +selected profile. + +The item lifecycle implementation uses three profiles: + +- `hostedEmulatorSmoke` for the default PR smoke case; +- `lifecycleConsistencyMatrix` for five account consistency configurations; +- `readConsistencyOverrideMatrix` for runtime and client default precedence. + +Operation-level `ReadConsistencyStrategy` cases, session-token choices, +acceptable transient statuses, retry deadlines, and assertions are defined in +the Rust test source. Future operation-specific dimensions follow the same +pattern instead of expanding the profile schema. + +The scheduled matrices in `e2e-consistency-matrix.json` and +`e2e-read-consistency-override-matrix.json` select every setup cell through +Gateway V1 and Gateway V2. Profile-driven jobs use the isolated `e2e` test +category. + ## Precedents and implementations -The `precedents` array records prior evidence for the expected behavior. A +The `precedents` array records prior evidence for expected behavior. A precedent can point to a service specification or an existing Rust, Java, -.NET, or Python test. It does not mean that the scenario is implemented only -for that SDK, and it is not the implementation registry. - -SDK implementations are tracked separately under `implementations/`. The -initial `rust.json` mapping links every scenario ID to an executable Rust E2E -test. Catalog validation fails if an active Rust mapping names a missing test, -if a scenario has no Rust mapping, or if a scenario is mapped more than once. -Future SDKs add their own implementation map without changing the -language-neutral scenario or its precedents. - -## Fixture variants - -Every scenario declares a `fixtures` array. Each fixture is one cohesive test -setup containing: - -- a container partition-key definition, including paths, kind, and version; -- named item definitions; -- each item's partition-key values and JSON document; -- whether the item is seeded before the scenario steps run. - -The same semantic steps run for every fixture unless an SDK implementation -documents a backend limitation. This makes partitioning variants explicit -without duplicating the scenario. For example, the duplicate-create scenario -runs against Hash V1, Hash V2, and hierarchical MultiHash V2 containers. - -Steps can reference a named fixture item through `itemRef`. Keeping the -partition-key values beside the corresponding document prevents setup data -from drifting away from its container definition. - -## Account profiles and execution cases - -Each reusable profile contains three arrays: `accounts`, `runtimes`, and -`clients`. Its configuration space is their Cartesian product. Every -definition has a stable ID, and an execution selects one account/runtime/client -cell by ID. Keeping several definitions in one focused profile avoids creating -one profile file for every combination. - -Runtime and client definitions can specify -`defaultReadConsistencyStrategy`. The execution's -`readConsistencyStrategy` is the operation-level value; `Inherit` leaves it -unset. This directly tests the precedence order operation > client > runtime > -account default. In particular, an explicit operation-level `Default` clears a -lower-layer non-default strategy and restores account-default read behavior. - -The item lifecycle scenario uses two bounded profiles rather than one giant -Cartesian product: - -- `lifecycleConsistencyMatrix`: five account definitions × one runtime × one - client × five operation strategies (25 executions per gateway); -- `readConsistencyOverrideMatrix`: one Session account × three runtime - definitions × two client definitions × three operation values (`Inherit`, - `Default`, and `Eventual`) (18 executions per gateway). - -Non-Strong two-region account definitions inject a deterministic replication -delay. Each execution declares `acceptableInitialStatuses`, a `terminalStatus`, -and, when transient statuses are accepted, `maxWaitMs`. The runner accepts an -immediate terminal result. Otherwise, it retries only public results matching -an acceptable status/substatus pair until it reaches the terminal pair or the -time cap. An omitted substatus matches any substatus; a missing response -substatus is normalized to zero when an explicit substatus is expected. - -SDK-internal retries can hide an acceptable transient status from the public -result. The runner includes matching internal diagnostic attempts in failure -evidence but does not require a transient attempt. This keeps the same scenario -valid for deterministic emulator delay and nondeterministic live replication. - -`LatestCommitted` means the latest committed value available in the selected -read region. It is not a cross-region replication barrier, so a delayed -secondary can return a temporary plain 404. Only a completed Strong-account -write guarantees that an eligible secondary can immediately return the item. +.NET, or Python test. It is not an implementation registry. -The scheduled matrices in `e2e-consistency-matrix.json` and -`e2e-read-consistency-override-matrix.json` execute every lifecycle case -through Gateway V1 and Gateway V2. Profile-driven jobs use the isolated `e2e` -test category so they do not rerun unrelated emulator suites. +SDK implementations are tracked under `implementations/`. The Rust mapping +links every scenario ID to an executable test in +`azure_data_cosmos/tests/e2e_test_cases/`. Catalog validation fails if an +active mapping names a missing test, a scenario references an unknown setup +profile, or a scenario has no Rust mapping. Future SDKs can add their own map +without changing scenario meaning. + +## Source-native assertions -## Assertions +The Rust implementations use only the public `azure_data_cosmos` surface for +product operations. Emulator orchestration uses the external management +endpoint. Concrete tests own resource fixtures, operation sequencing, data +invariants, HTTP status and substatus checks, diagnostics, retries, and cleanup. -Scenarios standardize outcomes, HTTP status and substatus, data side effects, -and selected structured diagnostics. Error text, serialized diagnostics, -opaque identifiers, exact request charge, and incidental timing are not stable -E2E assertions. +Error text, serialized diagnostics, opaque identifiers, exact request charge, +and incidental timing should not become stable E2E assertions. -Backend applicability is explicit: +Backend applicability values are: -- `required`: the scenario must execute and pass. -- `supported`: expected to work but not required in every pipeline. -- `simulated`: useful deterministic emulator behavior, not a service-fidelity claim. +- `required`: the scenario must execute and pass; +- `supported`: expected to work but not required in every pipeline; +- `simulated`: deterministic emulator behavior without a service-fidelity claim; - `notApplicable`: intentionally unavailable, with a reason. An unavailable required capability is a test configuration failure, never a silent skip. - -## Initial execution - -The Rust implementation is under -`azure_data_cosmos/tests/e2e_test_cases/`. Product operations use only the -public `azure_data_cosmos` surface. Emulator-only orchestration uses the -external management endpoint. diff --git a/sdk/cosmos/e2e_tests/implementations/rust.json b/sdk/cosmos/e2e_tests/implementations/rust.json index 655c3cbc34a..6f8b6d35288 100644 --- a/sdk/cosmos/e2e_tests/implementations/rust.json +++ b/sdk/cosmos/e2e_tests/implementations/rust.json @@ -3,15 +3,15 @@ "sdk": "rust", "testTarget": "e2e_tests", "scenarios": [ - { "id": "management.capabilities", "test": "capability_document_is_versioned", "status": "active" }, - { "id": "bootstrap.primary-success", "test": "bootstrap_primary_endpoint", "status": "active" }, - { "id": "item.lifecycle", "test": "item_lifecycle", "status": "active" }, - { "id": "item.upsert-create-update", "test": "upsert_creates_then_updates", "status": "active" }, - { "id": "item.create-conflict", "test": "duplicate_create_preserves_original", "status": "active" }, - { "id": "item.not-found-wrong-partition-key", "test": "not_found_does_not_cross_partition_keys", "status": "active" }, - { "id": "item.optimistic-concurrency", "test": "stale_etag_preserves_successful_update", "status": "active" }, - { "id": "query.parameterized-filter", "test": "parameterized_query_filters_and_orders", "status": "active" }, - { "id": "query.invalid-syntax", "test": "invalid_query_is_not_an_empty_feed", "status": "active" }, - { "id": "diagnostics.success-and-error", "test": "diagnostics_cover_success_and_error", "status": "active" } + { "id": "management.capabilities", "test": "capabilities::capability_document_is_versioned", "status": "active" }, + { "id": "bootstrap.primary-success", "test": "bootstrap_primary::bootstrap_primary_endpoint", "status": "active" }, + { "id": "item.lifecycle", "test": "lifecycle::item_lifecycle", "status": "active" }, + { "id": "item.upsert-create-update", "test": "item_upsert::upsert_creates_then_updates", "status": "active" }, + { "id": "item.create-conflict", "test": "item_create_conflict::duplicate_create_preserves_original", "status": "active" }, + { "id": "item.not-found-wrong-partition-key", "test": "item_not_found::not_found_does_not_cross_partition_keys", "status": "active" }, + { "id": "item.optimistic-concurrency", "test": "item_optimistic_concurrency::stale_etag_preserves_successful_update", "status": "active" }, + { "id": "query.parameterized-filter", "test": "query_parameterized_filter::parameterized_query_filters_and_orders", "status": "active" }, + { "id": "query.invalid-syntax", "test": "query_invalid_syntax::invalid_query_is_not_an_empty_feed", "status": "active" }, + { "id": "diagnostics.success-and-error", "test": "diagnostics_success_and_error::diagnostics_cover_success_and_error", "status": "active" } ] } \ No newline at end of file diff --git a/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json b/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json index 31ebf180429..6014121ed8f 100644 --- a/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json +++ b/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json @@ -1,30 +1,36 @@ { - "$schema": "../schema/profile.v1.json", - "specVersion": "1.0", - "id": "legacyGatewayV1", - "accounts": [{ - "id": "sessionSingleRegion", - "writeMode": "single", - "consistency": "session", - "regions": [ - { - "name": "East US" - } + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "legacyGatewayV1", + "accounts": [ + { + "id": "sessionSingleRegion", + "writeMode": "single", + "consistency": "session", + "regions": [ + { + "name": "East US" + } + ], + "replication": { + "minDelayMs": 0, + "maxDelayMs": 0 + }, + "perPartitionFailover": false + } ], - "replication": { - "minDelayMs": 0, - "maxDelayMs": 0 - }, - "perPartitionFailover": false - }], - "runtimes": [{ - "id": "gatewayV1PpcbDisabled", - "gatewayV2": "disabled", - "ppcb": "disabled" - }], - "clients": [{ - "id": "textAccountOrder", - "binaryEncoding": "disabled", - "routing": "accountOrder" - }] + "runtimes": [ + { + "id": "gatewayV1PpcbDisabled", + "gatewayV2": "disabled", + "ppcb": "disabled" + } + ], + "clients": [ + { + "id": "textAccountOrder", + "binaryEncoding": "disabled", + "routing": "accountOrder" + } + ] } diff --git a/sdk/cosmos/e2e_tests/profiles/targetDefault.json b/sdk/cosmos/e2e_tests/profiles/targetDefault.json index 2d3d1f3ca33..aa3c493cc30 100644 --- a/sdk/cosmos/e2e_tests/profiles/targetDefault.json +++ b/sdk/cosmos/e2e_tests/profiles/targetDefault.json @@ -1,33 +1,39 @@ { - "$schema": "../schema/profile.v1.json", - "specVersion": "1.0", - "id": "targetDefault", - "accounts": [{ - "id": "sessionTwoRegionDelayed", - "writeMode": "single", - "consistency": "session", - "regions": [ - { - "name": "East US" - }, - { - "name": "West US" - } + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "targetDefault", + "accounts": [ + { + "id": "sessionTwoRegionDelayed", + "writeMode": "single", + "consistency": "session", + "regions": [ + { + "name": "East US" + }, + { + "name": "West US" + } + ], + "replication": { + "minDelayMs": 500, + "maxDelayMs": 500 + }, + "perPartitionFailover": true + } ], - "replication": { - "minDelayMs": 500, - "maxDelayMs": 500 - }, - "perPartitionFailover": true - }], - "runtimes": [{ - "id": "gatewayV2Ppcb", - "gatewayV2": "enabled", - "ppcb": "enabled" - }], - "clients": [{ - "id": "binaryProximity", - "binaryEncoding": "enabled", - "routing": "proximity" - }] + "runtimes": [ + { + "id": "gatewayV2Ppcb", + "gatewayV2": "enabled", + "ppcb": "enabled" + } + ], + "clients": [ + { + "id": "binaryProximity", + "binaryEncoding": "enabled", + "routing": "proximity" + } + ] } diff --git a/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json index fee14a80ed9..465f81b700c 100644 --- a/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json +++ b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json @@ -12,7 +12,9 @@ "test": "item_crud" } ], - "profile": "hostedEmulatorSmoke", + "profiles": [ + "hostedEmulatorSmoke" + ], "tags": [ "prSmoke", "bootstrap" @@ -33,39 +35,5 @@ "applicability": "supported", "fidelity": "full" } - }, - "fixtures": [ - { - "id": "default", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [] - } - ], - "steps": [ - { - "id": "buildClient", - "action": { - "kind": "sdkOperation", - "operation": "buildClient" - }, - "expected": { - "outcome": "success", - "status": 201, - "state": { - "firstOperationSucceeds": true - } - } - } - ], - "cleanup": { - "deleteDatabase": true } } diff --git a/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json index 2634c0c71ff..2b6b7e5a409 100644 --- a/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json +++ b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json @@ -12,7 +12,9 @@ "test": "onlyCustomDiagnosticsHandler" } ], - "profile": "hostedEmulatorSmoke", + "profiles": [ + "hostedEmulatorSmoke" + ], "tags": [ "prSmoke", "diagnostics" @@ -33,116 +35,5 @@ "applicability": "supported", "fidelity": "full" } - }, - "fixtures": [ - { - "id": "hashV2", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [ - { - "id": "existing", - "seed": true, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "item-1", - "pk": "A", - "value": 1 - } - } - ] - } - ], - "steps": [ - { - "id": "successfulRead", - "action": { - "kind": "sdkOperation", - "operation": "readItem", - "input": { - "id": "item-1", - "pk": "A" - } - }, - "expected": { - "outcome": "success", - "status": 200 - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "read_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 200 - }, - "requestCount": { - "comparator": "atLeast", - "value": 1 - }, - "regionsContacted": { - "comparator": "contains", - "value": [ - "eastus" - ] - } - } - }, - { - "id": "missingRead", - "action": { - "kind": "sdkOperation", - "operation": "readItem", - "input": { - "id": "missing", - "pk": "A" - } - }, - "expected": { - "outcome": "error", - "status": 404, - "subStatus": 0, - "errorCategory": "notFound" - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "read_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 404 - }, - "requestCount": { - "comparator": "atLeast", - "value": 1 - }, - "regionsContacted": { - "comparator": "contains", - "value": [ - "eastus" - ] - } - } - } - ], - "cleanup": { - "deleteDatabase": true } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json index d07f03f1bde..f60e07423d3 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json @@ -12,7 +12,9 @@ "test": "duplicate document create" } ], - "profile": "hostedEmulatorSmoke", + "profiles": [ + "hostedEmulatorSmoke" + ], "tags": [ "prSmoke", "item", @@ -34,168 +36,5 @@ "applicability": "supported", "fidelity": "full" } - }, - "fixtures": [ - { - "id": "hashV1", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 1 - } - }, - "items": [ - { - "id": "original", - "seed": true, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "duplicate-1", - "pk": "A", - "value": 1 - } - }, - { - "id": "duplicate", - "seed": false, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "duplicate-1", - "pk": "A", - "value": 2 - } - } - ] - }, - { - "id": "hashV2", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [ - { - "id": "original", - "seed": true, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "duplicate-1", - "pk": "A", - "value": 1 - } - }, - { - "id": "duplicate", - "seed": false, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "duplicate-1", - "pk": "A", - "value": 2 - } - } - ] - }, - { - "id": "hierarchicalV2", - "container": { - "partitionKey": { - "paths": [ - "/tenant", - "/user" - ], - "kind": "MultiHash", - "version": 2 - } - }, - "items": [ - { - "id": "original", - "seed": true, - "partitionKeyValues": [ - "tenant-a", - "user-1" - ], - "document": { - "id": "duplicate-1", - "tenant": "tenant-a", - "user": "user-1", - "value": 1 - } - }, - { - "id": "duplicate", - "seed": false, - "partitionKeyValues": [ - "tenant-a", - "user-1" - ], - "document": { - "id": "duplicate-1", - "tenant": "tenant-a", - "user": "user-1", - "value": 2 - } - } - ] - } - ], - "steps": [ - { - "id": "duplicateCreate", - "action": { - "kind": "sdkOperation", - "operation": "createItem", - "input": { - "itemRef": "duplicate" - } - }, - "expected": { - "outcome": "error", - "status": 409, - "subStatus": 0, - "errorCategory": "conflict", - "state": { - "itemRef": "original", - "unchanged": true - } - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "create_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 409 - }, - "requestCount": { - "comparator": "atLeast", - "value": 1 - } - } - } - ], - "cleanup": { - "deleteDatabase": true } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json index 0c8c60d21d0..1623067278c 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json @@ -12,6 +12,11 @@ "test": "createItem/readItem/replaceItem/deleteItem" } ], + "profiles": [ + "hostedEmulatorSmoke", + "lifecycleConsistencyMatrix", + "readConsistencyOverrideMatrix" + ], "tags": [ "prSmoke", "item" @@ -32,569 +37,5 @@ "applicability": "supported", "fidelity": "full" } - }, - "fixtures": [ - { - "id": "hashV2", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [] - } - ], - "executions": [ - { - "id": "hostedSmokeDefault", - "profile": "hostedEmulatorSmoke", - "account": "sessionSingleRegion", - "runtime": "sdkDefault", - "client": "sdkDefault", - "readConsistencyStrategy": "Default", - "readRegion": "East US", - "sessionToken": "automatic", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 200 } - } - }, - { - "id": "strongDefault", - "profile": "lifecycleConsistencyMatrix", - "account": "strong", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Default", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 200 } - } - }, - { - "id": "strongEventual", - "profile": "lifecycleConsistencyMatrix", - "account": "strong", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Eventual", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 200 } - } - }, - { - "id": "strongSession", - "profile": "lifecycleConsistencyMatrix", - "account": "strong", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Session", - "readRegion": "West US", - "sessionToken": "createResponse", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 200 } - } - }, - { - "id": "strongLatestCommitted", - "profile": "lifecycleConsistencyMatrix", - "account": "strong", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "LatestCommitted", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 200 } - } - }, - { - "id": "strongGlobalStrong", - "profile": "lifecycleConsistencyMatrix", - "account": "strong", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "GlobalStrong", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 200 } - } - }, - { - "id": "boundedDefault", - "profile": "lifecycleConsistencyMatrix", - "account": "boundedStaleness", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Default", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "boundedEventual", - "profile": "lifecycleConsistencyMatrix", - "account": "boundedStaleness", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Eventual", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "boundedSession", - "profile": "lifecycleConsistencyMatrix", - "account": "boundedStaleness", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Session", - "readRegion": "West US", - "sessionToken": "createResponse", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "boundedLatestCommitted", - "profile": "lifecycleConsistencyMatrix", - "account": "boundedStaleness", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "LatestCommitted", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "boundedGlobalStrong", - "profile": "lifecycleConsistencyMatrix", - "account": "boundedStaleness", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "GlobalStrong", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 400 } - } - }, - { - "id": "sessionDefault", - "profile": "lifecycleConsistencyMatrix", - "account": "session", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Default", - "readRegion": "West US", - "sessionToken": "automatic", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "sessionEventual", - "profile": "lifecycleConsistencyMatrix", - "account": "session", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Eventual", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "sessionSession", - "profile": "lifecycleConsistencyMatrix", - "account": "session", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Session", - "readRegion": "West US", - "sessionToken": "createResponse", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "sessionLatestCommitted", - "profile": "lifecycleConsistencyMatrix", - "account": "session", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "LatestCommitted", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "sessionGlobalStrong", - "profile": "lifecycleConsistencyMatrix", - "account": "session", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "GlobalStrong", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 400 } - } - }, - { - "id": "prefixDefault", - "profile": "lifecycleConsistencyMatrix", - "account": "consistentPrefix", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Default", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "prefixEventual", - "profile": "lifecycleConsistencyMatrix", - "account": "consistentPrefix", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Eventual", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "prefixSession", - "profile": "lifecycleConsistencyMatrix", - "account": "consistentPrefix", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Session", - "readRegion": "West US", - "sessionToken": "createResponse", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "prefixLatestCommitted", - "profile": "lifecycleConsistencyMatrix", - "account": "consistentPrefix", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "LatestCommitted", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "prefixGlobalStrong", - "profile": "lifecycleConsistencyMatrix", - "account": "consistentPrefix", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "GlobalStrong", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 400 } - } - }, - { - "id": "eventualDefault", - "profile": "lifecycleConsistencyMatrix", - "account": "eventual", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Default", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "eventualEventual", - "profile": "lifecycleConsistencyMatrix", - "account": "eventual", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Eventual", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "eventualSession", - "profile": "lifecycleConsistencyMatrix", - "account": "eventual", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "Session", - "readRegion": "West US", - "sessionToken": "createResponse", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "eventualLatestCommitted", - "profile": "lifecycleConsistencyMatrix", - "account": "eventual", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "LatestCommitted", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], - "terminalStatus": { "statusCode": 200 }, - "maxWaitMs": 5000 - } - }, - { - "id": "eventualGlobalStrong", - "profile": "lifecycleConsistencyMatrix", - "account": "eventual", - "runtime": "unset", - "client": "unset", - "readConsistencyStrategy": "GlobalStrong", - "readRegion": "West US", - "sessionToken": "none", - "expectedRead": { - "acceptableInitialStatuses": [], - "terminalStatus": { "statusCode": 400 } - } - }, - { "id": "overrideUnsetUnsetInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "unset", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "automatic", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideEventualUnsetInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "unset", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideSessionUnsetInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "unset", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "automatic", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideUnsetLatestInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "latestCommitted", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideEventualLatestInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "latestCommitted", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideSessionLatestInherit", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "latestCommitted", "readConsistencyStrategy": "Inherit", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - - { "id": "overrideUnsetUnsetDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "unset", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideEventualUnsetDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "unset", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideSessionUnsetDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "unset", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideUnsetLatestDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "latestCommitted", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideEventualLatestDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "latestCommitted", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideSessionLatestDefault", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "latestCommitted", "readConsistencyStrategy": "Default", "readRegion": "West US", "sessionToken": "createResponse", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 1002 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - - { "id": "overrideUnsetUnsetEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "unset", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideEventualUnsetEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "unset", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideSessionUnsetEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "unset", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideUnsetLatestEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "unset", "client": "latestCommitted", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideEventualLatestEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "eventual", "client": "latestCommitted", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } }, - { "id": "overrideSessionLatestEventual", "profile": "readConsistencyOverrideMatrix", "account": "session", "runtime": "session", "client": "latestCommitted", "readConsistencyStrategy": "Eventual", "readRegion": "West US", "sessionToken": "none", "expectedRead": { "acceptableInitialStatuses": [{ "statusCode": 404, "subStatusCode": 0 }], "terminalStatus": { "statusCode": 200 }, "maxWaitMs": 5000 } } - ], - "steps": [ - { - "id": "create", - "action": { - "kind": "sdkOperation", - "operation": "createItem", - "input": { - "id": "item-1", - "pk": "A", - "value": 1 - } - }, - "expected": { - "outcome": "success", - "status": 201, - "state": { - "value": 1 - } - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "create_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 201 - }, - "requestCount": { - "comparator": "atLeast", - "value": 1 - } - } - }, - { - "id": "read", - "action": { - "kind": "sdkOperation", - "operation": "readItem", - "input": { - "id": "item-1", - "pk": "A" - } - }, - "expected": { - "outcome": "success", - "status": 200, - "state": { - "value": 1 - } - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "read_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 200 - }, - "requestCount": { - "comparator": "atLeast", - "value": 1 - } - } - }, - { - "id": "replace", - "action": { - "kind": "sdkOperation", - "operation": "replaceItem", - "input": { - "id": "item-1", - "pk": "A", - "value": 2 - } - }, - "expected": { - "outcome": "success", - "status": 200, - "state": { - "value": 2 - } - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "replace_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 200 - }, - "requestCount": { - "comparator": "atLeast", - "value": 1 - } - } - }, - { - "id": "delete", - "action": { - "kind": "sdkOperation", - "operation": "deleteItem", - "input": { - "id": "item-1", - "pk": "A" - } - }, - "expected": { - "outcome": "success", - "status": 204, - "state": { - "exists": false - } - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "delete_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 204 - }, - "requestCount": { - "comparator": "atLeast", - "value": 1 - } - } - } - ], - "cleanup": { - "deleteDatabase": true } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json index e220ce5e71a..0eea775044d 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json @@ -12,7 +12,9 @@ "test": "read_item_not_found" } ], - "profile": "hostedEmulatorSmoke", + "profiles": [ + "hostedEmulatorSmoke" + ], "tags": [ "prSmoke", "item", @@ -34,101 +36,5 @@ "applicability": "supported", "fidelity": "full" } - }, - "fixtures": [ - { - "id": "hashV2", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [ - { - "id": "existing", - "seed": true, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "item-1", - "pk": "A", - "value": 1 - } - } - ] - } - ], - "steps": [ - { - "id": "missing", - "action": { - "kind": "sdkOperation", - "operation": "readItem", - "input": { - "id": "missing", - "pk": "A" - } - }, - "expected": { - "outcome": "error", - "status": 404, - "subStatus": 0, - "errorCategory": "notFound" - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "read_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 404 - } - } - }, - { - "id": "wrongPartitionKey", - "action": { - "kind": "sdkOperation", - "operation": "readItem", - "input": { - "id": "item-1", - "pk": "B" - } - }, - "expected": { - "outcome": "error", - "status": 404, - "subStatus": 0, - "errorCategory": "notFound", - "state": { - "originalUnchanged": true - } - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "read_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 404 - } - } - } - ], - "cleanup": { - "deleteDatabase": true } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json index 7a0d046916c..2ae7aca8287 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json @@ -12,7 +12,9 @@ "test": "ItemRequestOptionAccessConditionTest" } ], - "profile": "hostedEmulatorSmoke", + "profiles": [ + "hostedEmulatorSmoke" + ], "tags": [ "prSmoke", "item", @@ -34,93 +36,5 @@ "applicability": "supported", "fidelity": "full" } - }, - "fixtures": [ - { - "id": "hashV2", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [ - { - "id": "existing", - "seed": true, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "etag-1", - "pk": "A", - "value": 1 - } - } - ] - } - ], - "steps": [ - { - "id": "replaceCurrent", - "action": { - "kind": "sdkOperation", - "operation": "replaceItem", - "input": { - "id": "etag-1", - "pk": "A", - "value": 2, - "ifMatch": "initialEtag" - } - }, - "expected": { - "outcome": "success", - "status": 200, - "state": { - "value": 2 - } - } - }, - { - "id": "replaceStale", - "action": { - "kind": "sdkOperation", - "operation": "replaceItem", - "input": { - "id": "etag-1", - "pk": "A", - "value": 3, - "ifMatch": "initialEtag" - } - }, - "expected": { - "outcome": "error", - "status": 412, - "subStatus": 0, - "errorCategory": "preconditionFailed", - "state": { - "value": 2 - } - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "replace_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 412 - } - } - } - ], - "cleanup": { - "deleteDatabase": true } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json index 58dc5046cbc..049f882cc0b 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json @@ -12,7 +12,9 @@ "test": "UpsertItemTest" } ], - "profile": "hostedEmulatorSmoke", + "profiles": [ + "hostedEmulatorSmoke" + ], "tags": [ "prSmoke", "item" @@ -33,90 +35,5 @@ "applicability": "supported", "fidelity": "full" } - }, - "fixtures": [ - { - "id": "hashV2", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [] - } - ], - "steps": [ - { - "id": "upsertCreate", - "action": { - "kind": "sdkOperation", - "operation": "upsertItem", - "input": { - "id": "upsert-1", - "pk": "A", - "value": 1 - } - }, - "expected": { - "outcome": "success", - "status": 201, - "state": { - "value": 1 - } - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "upsert_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 201 - } - } - }, - { - "id": "upsertUpdate", - "action": { - "kind": "sdkOperation", - "operation": "upsertItem", - "input": { - "id": "upsert-1", - "pk": "A", - "value": 2 - } - }, - "expected": { - "outcome": "success", - "status": 200, - "state": { - "value": 2, - "itemCount": 1 - } - }, - "diagnostics": { - "operationName": { - "comparator": "exact", - "value": "upsert_item" - }, - "activityId": { - "comparator": "present" - }, - "effectiveStatus": { - "comparator": "exact", - "value": 200 - } - } - } - ], - "cleanup": { - "deleteDatabase": true } } diff --git a/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json index 0ce6b2b0870..8eea2afb579 100644 --- a/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json +++ b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json @@ -12,7 +12,9 @@ "test": "capabilities_report_configured_protocols" } ], - "profile": "hostedEmulatorSmoke", + "profiles": [ + "hostedEmulatorSmoke" + ], "tags": [ "prSmoke", "management" @@ -38,40 +40,5 @@ "fidelity": "none", "reason": "The capability document is specific to emulator E2E test orchestration." } - }, - "fixtures": [ - { - "id": "default", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [] - } - ], - "steps": [ - { - "id": "readCapabilities", - "action": { - "kind": "managementRequest", - "operation": "readCapabilities" - }, - "expected": { - "outcome": "success", - "status": 200, - "state": { - "apiVersion": 1, - "gatewayV1": true - } - } - } - ], - "cleanup": { - "deleteDatabase": false } } diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json index ae61cbdcc4e..4c90544fabd 100644 --- a/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json +++ b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json @@ -12,7 +12,9 @@ "test": "NegativeQueryTest" } ], - "profile": "hostedEmulatorSmoke", + "profiles": [ + "hostedEmulatorSmoke" + ], "tags": [ "prSmoke", "query", @@ -34,41 +36,5 @@ "applicability": "supported", "fidelity": "full" } - }, - "fixtures": [ - { - "id": "hashV2", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [] - } - ], - "steps": [ - { - "id": "invalidQuery", - "action": { - "kind": "sdkOperation", - "operation": "queryItems", - "input": { - "text": "SELECT FROM", - "partitionKey": "A" - } - }, - "expected": { - "outcome": "error", - "status": 400, - "errorCategory": "badRequest" - } - } - ], - "cleanup": { - "deleteDatabase": true } } diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json index 493d804499b..8923ae50f2c 100644 --- a/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json +++ b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json @@ -12,7 +12,9 @@ "test": "queryItems" } ], - "profile": "hostedEmulatorSmoke", + "profiles": [ + "hostedEmulatorSmoke" + ], "tags": [ "prSmoke", "query" @@ -33,93 +35,5 @@ "applicability": "supported", "fidelity": "full" } - }, - "fixtures": [ - { - "id": "hashV2", - "container": { - "partitionKey": { - "paths": [ - "/pk" - ], - "kind": "Hash", - "version": 2 - } - }, - "items": [ - { - "id": "item1", - "seed": true, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "item-1", - "pk": "A", - "score": 1 - } - }, - { - "id": "item2", - "seed": true, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "item-2", - "pk": "A", - "score": 2 - } - }, - { - "id": "item3", - "seed": true, - "partitionKeyValues": [ - "A" - ], - "document": { - "id": "item-3", - "pk": "A", - "score": 3 - } - } - ] - } - ], - "steps": [ - { - "id": "query", - "action": { - "kind": "sdkOperation", - "operation": "queryItems", - "input": { - "text": "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score ASC", - "parameters": [ - { - "name": "@pk", - "value": "A" - }, - { - "name": "@min", - "value": 2 - } - ], - "partitionKey": "A" - } - }, - "expected": { - "outcome": "success", - "status": 200, - "state": { - "ids": [ - "item-2", - "item-3" - ] - } - } - } - ], - "cleanup": { - "deleteDatabase": true } } diff --git a/sdk/cosmos/e2e_tests/schema/profile.v1.json b/sdk/cosmos/e2e_tests/schema/profile.v1.json index e01052553fa..aaaa075ae35 100644 --- a/sdk/cosmos/e2e_tests/schema/profile.v1.json +++ b/sdk/cosmos/e2e_tests/schema/profile.v1.json @@ -5,6 +5,7 @@ "type": "object", "additionalProperties": false, "required": [ + "$schema", "specVersion", "id", "accounts", @@ -13,7 +14,7 @@ ], "properties": { "$schema": { - "type": "string" + "const": "../schema/profile.v1.json" }, "specVersion": { "const": "1.0" diff --git a/sdk/cosmos/e2e_tests/schema/scenario.v1.json b/sdk/cosmos/e2e_tests/schema/scenario.v1.json index da085b463d9..0bdc69bff43 100644 --- a/sdk/cosmos/e2e_tests/schema/scenario.v1.json +++ b/sdk/cosmos/e2e_tests/schema/scenario.v1.json @@ -1,29 +1,24 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://azure.github.io/cosmos-sdk-e2e-tests/scenario.v1.json", - "title": "Cosmos SDK E2E test scenario", + "title": "Cosmos SDK E2E test scenario metadata", "type": "object", "additionalProperties": false, "required": [ + "$schema", "specVersion", "id", "title", "requirement", "maturity", "precedents", + "profiles", "tags", - "backends", - "fixtures", - "steps", - "cleanup" - ], - "oneOf": [ - { "required": ["profile"], "not": { "required": ["executions"] } }, - { "required": ["executions"], "not": { "required": ["profile"] } } + "backends" ], "properties": { "$schema": { - "type": "string" + "const": "../../schema/scenario.v1.json" }, "specVersion": { "const": "1.0" @@ -51,37 +46,17 @@ "type": "array", "minItems": 1, "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "sdk", - "path", - "test" - ], - "properties": { - "sdk": { - "enum": [ - "service", - "rust", - "java", - "dotnet", - "python" - ] - }, - "path": { - "type": "string", - "minLength": 1 - }, - "test": { - "type": "string", - "minLength": 1 - } - } + "$ref": "#/$defs/reference" } }, - "profile": { - "type": "string", - "minLength": 1 + "profiles": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } }, "tags": { "type": "array", @@ -111,184 +86,34 @@ "$ref": "#/$defs/backend" } } - }, - "fixtures": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/fixture" - } - }, - "executions": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/execution" - } - }, - "steps": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/step" - } - }, - "cleanup": { - "type": "object", - "additionalProperties": false, - "required": [ - "deleteDatabase" - ], - "properties": { - "deleteDatabase": { - "type": "boolean" - } - } } }, "$defs": { - "execution": { + "reference": { "type": "object", "additionalProperties": false, "required": [ - "id", - "profile", - "account", - "runtime", - "client", - "readConsistencyStrategy", - "readRegion", - "sessionToken", - "expectedRead" + "sdk", + "path", + "test" ], "properties": { - "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" }, - "profile": { "type": "string", "minLength": 1 }, - "account": { "type": "string", "minLength": 1 }, - "runtime": { "type": "string", "minLength": 1 }, - "client": { "type": "string", "minLength": 1 }, - "readConsistencyStrategy": { - "enum": ["Inherit", "Default", "Eventual", "Session", "LatestCommitted", "GlobalStrong"] + "sdk": { + "enum": [ + "service", + "rust", + "java", + "dotnet", + "python" + ] }, - "readRegion": { "type": "string", "minLength": 1 }, - "sessionToken": { "enum": ["automatic", "createResponse", "none"] }, - "expectedRead": { - "type": "object", - "additionalProperties": false, - "required": ["acceptableInitialStatuses", "terminalStatus"], - "properties": { - "acceptableInitialStatuses": { - "type": "array", - "uniqueItems": true, - "items": { "$ref": "#/$defs/status" } - }, - "terminalStatus": { "$ref": "#/$defs/status" }, - "maxWaitMs": { "type": "integer", "minimum": 1 } - } - } - } - }, - "status": { - "type": "object", - "additionalProperties": false, - "required": ["statusCode"], - "properties": { - "statusCode": { "type": "integer", "minimum": 100, "maximum": 599 }, - "subStatusCode": { "type": "integer", "minimum": 0 } - } - }, - "fixture": { - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "container", - "items" - ], - "properties": { - "id": { + "path": { "type": "string", - "pattern": "^[a-z][a-zA-Z0-9]*$" - }, - "container": { - "type": "object", - "additionalProperties": false, - "required": [ - "partitionKey" - ], - "properties": { - "partitionKey": { - "type": "object", - "additionalProperties": false, - "required": [ - "paths", - "kind", - "version" - ], - "properties": { - "paths": { - "type": "array", - "minItems": 1, - "maxItems": 3, - "uniqueItems": true, - "items": { - "type": "string", - "pattern": "^/[^/]+" - } - }, - "kind": { - "enum": [ - "Hash", - "MultiHash" - ] - }, - "version": { - "enum": [ - 1, - 2 - ] - } - } - } - } + "minLength": 1 }, - "items": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "seed", - "partitionKeyValues", - "document" - ], - "properties": { - "id": { - "type": "string", - "pattern": "^[a-z][a-zA-Z0-9]*$" - }, - "seed": { - "type": "boolean" - }, - "partitionKeyValues": { - "type": "array", - "minItems": 1, - "maxItems": 3, - "items": { - "type": [ - "string", - "number", - "boolean", - "null" - ] - } - }, - "document": { - "type": "object" - } - } - } + "test": { + "type": "string", + "minLength": 1 } } }, @@ -329,118 +154,6 @@ } } } - }, - "step": { - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "action", - "expected" - ], - "properties": { - "id": { - "type": "string", - "pattern": "^[a-z][a-zA-Z0-9]*$" - }, - "action": { - "type": "object", - "additionalProperties": false, - "required": [ - "kind", - "operation" - ], - "properties": { - "kind": { - "enum": [ - "sdkOperation", - "managementRequest" - ] - }, - "operation": { - "type": "string", - "minLength": 1 - }, - "input": {} - } - }, - "expected": { - "type": "object", - "additionalProperties": false, - "required": [ - "outcome", - "status" - ], - "properties": { - "outcome": { - "enum": [ - "success", - "error" - ] - }, - "status": { - "type": "integer", - "minimum": 100, - "maximum": 599 - }, - "subStatus": { - "type": "integer", - "minimum": 0 - }, - "errorCategory": { - "type": "string", - "minLength": 1 - }, - "state": {} - } - }, - "diagnostics": { - "$ref": "#/$defs/diagnostics" - } - } - }, - "diagnostics": { - "type": "object", - "additionalProperties": false, - "properties": { - "operationName": { - "$ref": "#/$defs/comparison" - }, - "activityId": { - "$ref": "#/$defs/comparison" - }, - "effectiveStatus": { - "$ref": "#/$defs/comparison" - }, - "requestCount": { - "$ref": "#/$defs/comparison" - }, - "regionsContacted": { - "$ref": "#/$defs/comparison" - } - } - }, - "comparison": { - "type": "object", - "additionalProperties": false, - "required": [ - "comparator" - ], - "properties": { - "comparator": { - "enum": [ - "exact", - "present", - "absent", - "contains", - "atLeast", - "atMost", - "setEquals", - "orderedSubsequence" - ] - }, - "value": {} - } } } } diff --git a/sdk/cosmos/e2e_tests/vocabulary/v1.json b/sdk/cosmos/e2e_tests/vocabulary/v1.json deleted file mode 100644 index be95fd28a7e..00000000000 --- a/sdk/cosmos/e2e_tests/vocabulary/v1.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "specVersion": "1.0", - "backends": ["hostedEmulatorGatewayV1", "hostedEmulatorGatewayV2", "azureLive"], - "applicability": ["required", "supported", "simulated", "notApplicable"], - "maturity": ["candidate", "stable", "deprecated"], - "operations": [ - "readCapabilities", - "buildClient", - "createItem", - "readItem", - "replaceItem", - "upsertItem", - "deleteItem", - "queryItems" - ], - "errorCategories": ["badRequest", "notFound", "conflict", "preconditionFailed"], - "diagnosticComparators": ["exact", "present", "absent", "contains", "atLeast", "atMost", "setEquals", "orderedSubsequence"] -} From c25f57145505e3676e14dcaa023bb2eeb9c966b9 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Thu, 10 Sep 2026 16:30:33 +0000 Subject: [PATCH 03/19] Formatting fixes --- sdk/cosmos/e2e-consistency-matrix.json | 31 +++- sdk/cosmos/e2e-profile-matrix.json | 33 ++++ .../e2e-read-consistency-override-matrix.json | 60 +++++--- .../e2e_tests/implementations/rust.json | 62 ++++++-- .../profiles/hostedEmulatorSmoke.json | 56 ++++--- .../scenarios/bootstrap/primary-success.json | 72 ++++----- .../diagnostics/success-and-error.json | 72 ++++----- .../scenarios/items/create-conflict.json | 74 ++++----- .../e2e_tests/scenarios/items/lifecycle.json | 76 +++++----- .../items/not-found-wrong-partition-key.json | 74 ++++----- .../items/optimistic-concurrency.json | 74 ++++----- .../scenarios/items/upsert-create-update.json | 72 ++++----- .../scenarios/management/capabilities.json | 82 +++++----- .../scenarios/queries/invalid-syntax.json | 74 ++++----- .../queries/parameterized-filter.json | 72 ++++----- sdk/cosmos/e2e_tests/schema/profile.v1.json | 143 +++++++++++++++--- 16 files changed, 667 insertions(+), 460 deletions(-) create mode 100644 sdk/cosmos/e2e-profile-matrix.json diff --git a/sdk/cosmos/e2e-consistency-matrix.json b/sdk/cosmos/e2e-consistency-matrix.json index 75a52545c56..1fe87152a19 100644 --- a/sdk/cosmos/e2e-consistency-matrix.json +++ b/sdk/cosmos/e2e-consistency-matrix.json @@ -15,11 +15,28 @@ "Pool": "env:LINUXPOOL" } }, - "RustToolchainName": ["stable"], - "AZURE_COSMOS_EMULATOR_FLAVOR": ["inmemory-v1", "inmemory-v2"], - "AZURE_COSMOS_E2E_PROFILE": ["lifecycleConsistencyMatrix"], - "AZURE_COSMOS_E2E_ACCOUNT": ["strong", "boundedStaleness", "session", "consistentPrefix", "eventual"], - "AZURE_COSMOS_E2E_RUNTIME": ["unset"], - "AZURE_COSMOS_E2E_CLIENT": ["unset"] + "RustToolchainName": [ + "stable" + ], + "AZURE_COSMOS_EMULATOR_FLAVOR": [ + "inmemory-v1", + "inmemory-v2" + ], + "AZURE_COSMOS_E2E_PROFILE": [ + "lifecycleConsistencyMatrix" + ], + "AZURE_COSMOS_E2E_ACCOUNT": [ + "strong", + "boundedStaleness", + "session", + "consistentPrefix", + "eventual" + ], + "AZURE_COSMOS_E2E_RUNTIME": [ + "unset" + ], + "AZURE_COSMOS_E2E_CLIENT": [ + "unset" + ] } -} \ No newline at end of file +} diff --git a/sdk/cosmos/e2e-profile-matrix.json b/sdk/cosmos/e2e-profile-matrix.json new file mode 100644 index 00000000000..159c59b04d3 --- /dev/null +++ b/sdk/cosmos/e2e-profile-matrix.json @@ -0,0 +1,33 @@ +{ + "displayNames": { + "inmemory-v1": "gateway_v1", + "inmemory-v2": "gateway_v2", + "strongTwoRegion": "strong_2_regions", + "boundedStalenessTwoRegionDelayed": "bounded_staleness_2_regions_delayed", + "sessionTwoRegionDelayed": "session_2_regions_delayed", + "consistentPrefixTwoRegionDelayed": "consistent_prefix_2_regions_delayed", + "eventualTwoRegionDelayed": "eventual_2_regions_delayed" + }, + "matrix": { + "Agent": { + "ubuntu": { + "OSVmImage": "env:LINUXVMIMAGE", + "Pool": "env:LINUXPOOL" + } + }, + "RustToolchainName": [ + "stable" + ], + "AZURE_COSMOS_EMULATOR_FLAVOR": [ + "inmemory-v1", + "inmemory-v2" + ], + "AZURE_COSMOS_E2E_PROFILE": [ + "strongTwoRegion", + "boundedStalenessTwoRegionDelayed", + "sessionTwoRegionDelayed", + "consistentPrefixTwoRegionDelayed", + "eventualTwoRegionDelayed" + ] + } +} diff --git a/sdk/cosmos/e2e-read-consistency-override-matrix.json b/sdk/cosmos/e2e-read-consistency-override-matrix.json index 90d19fe80fe..6f8ffb6b358 100644 --- a/sdk/cosmos/e2e-read-consistency-override-matrix.json +++ b/sdk/cosmos/e2e-read-consistency-override-matrix.json @@ -1,24 +1,40 @@ { - "displayNames": { - "inmemory-v1": "gateway_v1", - "inmemory-v2": "gateway_v2", - "unset": "unset", - "eventual": "eventual", - "session": "session", - "latestCommitted": "latest_committed" - }, - "matrix": { - "Agent": { - "ubuntu": { - "OSVmImage": "env:LINUXVMIMAGE", - "Pool": "env:LINUXPOOL" - } + "displayNames": { + "inmemory-v1": "gateway_v1", + "inmemory-v2": "gateway_v2", + "unset": "unset", + "eventual": "eventual", + "session": "session", + "latestCommitted": "latest_committed" }, - "RustToolchainName": ["stable"], - "AZURE_COSMOS_EMULATOR_FLAVOR": ["inmemory-v1", "inmemory-v2"], - "AZURE_COSMOS_E2E_PROFILE": ["readConsistencyOverrideMatrix"], - "AZURE_COSMOS_E2E_ACCOUNT": ["session"], - "AZURE_COSMOS_E2E_RUNTIME": ["unset", "eventual", "session"], - "AZURE_COSMOS_E2E_CLIENT": ["unset", "latestCommitted"] - } -} \ No newline at end of file + "matrix": { + "Agent": { + "ubuntu": { + "OSVmImage": "env:LINUXVMIMAGE", + "Pool": "env:LINUXPOOL" + } + }, + "RustToolchainName": [ + "stable" + ], + "AZURE_COSMOS_EMULATOR_FLAVOR": [ + "inmemory-v1", + "inmemory-v2" + ], + "AZURE_COSMOS_E2E_PROFILE": [ + "readConsistencyOverrideMatrix" + ], + "AZURE_COSMOS_E2E_ACCOUNT": [ + "session" + ], + "AZURE_COSMOS_E2E_RUNTIME": [ + "unset", + "eventual", + "session" + ], + "AZURE_COSMOS_E2E_CLIENT": [ + "unset", + "latestCommitted" + ] + } +} diff --git a/sdk/cosmos/e2e_tests/implementations/rust.json b/sdk/cosmos/e2e_tests/implementations/rust.json index 6f8b6d35288..7df1b265512 100644 --- a/sdk/cosmos/e2e_tests/implementations/rust.json +++ b/sdk/cosmos/e2e_tests/implementations/rust.json @@ -3,15 +3,55 @@ "sdk": "rust", "testTarget": "e2e_tests", "scenarios": [ - { "id": "management.capabilities", "test": "capabilities::capability_document_is_versioned", "status": "active" }, - { "id": "bootstrap.primary-success", "test": "bootstrap_primary::bootstrap_primary_endpoint", "status": "active" }, - { "id": "item.lifecycle", "test": "lifecycle::item_lifecycle", "status": "active" }, - { "id": "item.upsert-create-update", "test": "item_upsert::upsert_creates_then_updates", "status": "active" }, - { "id": "item.create-conflict", "test": "item_create_conflict::duplicate_create_preserves_original", "status": "active" }, - { "id": "item.not-found-wrong-partition-key", "test": "item_not_found::not_found_does_not_cross_partition_keys", "status": "active" }, - { "id": "item.optimistic-concurrency", "test": "item_optimistic_concurrency::stale_etag_preserves_successful_update", "status": "active" }, - { "id": "query.parameterized-filter", "test": "query_parameterized_filter::parameterized_query_filters_and_orders", "status": "active" }, - { "id": "query.invalid-syntax", "test": "query_invalid_syntax::invalid_query_is_not_an_empty_feed", "status": "active" }, - { "id": "diagnostics.success-and-error", "test": "diagnostics_success_and_error::diagnostics_cover_success_and_error", "status": "active" } + { + "id": "management.capabilities", + "test": "capabilities::capability_document_is_versioned", + "status": "active" + }, + { + "id": "bootstrap.primary-success", + "test": "bootstrap_primary::bootstrap_primary_endpoint", + "status": "active" + }, + { + "id": "item.lifecycle", + "test": "lifecycle::item_lifecycle", + "status": "active" + }, + { + "id": "item.upsert-create-update", + "test": "item_upsert::upsert_creates_then_updates", + "status": "active" + }, + { + "id": "item.create-conflict", + "test": "item_create_conflict::duplicate_create_preserves_original", + "status": "active" + }, + { + "id": "item.not-found-wrong-partition-key", + "test": "item_not_found::not_found_does_not_cross_partition_keys", + "status": "active" + }, + { + "id": "item.optimistic-concurrency", + "test": "item_optimistic_concurrency::stale_etag_preserves_successful_update", + "status": "active" + }, + { + "id": "query.parameterized-filter", + "test": "query_parameterized_filter::parameterized_query_filters_and_orders", + "status": "active" + }, + { + "id": "query.invalid-syntax", + "test": "query_invalid_syntax::invalid_query_is_not_an_empty_feed", + "status": "active" + }, + { + "id": "diagnostics.success-and-error", + "test": "diagnostics_success_and_error::diagnostics_cover_success_and_error", + "status": "active" + } ] -} \ No newline at end of file +} diff --git a/sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json b/sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json index ce566dbdb06..26a517b5078 100644 --- a/sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json +++ b/sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json @@ -2,29 +2,35 @@ "$schema": "../schema/profile.v1.json", "specVersion": "1.0", "id": "hostedEmulatorSmoke", - "accounts": [{ - "id": "sessionSingleRegion", - "writeMode": "single", - "consistency": "session", - "regions": [ - { - "name": "East US" - } - ], - "replication": { - "minDelayMs": 0, - "maxDelayMs": 0 - }, - "perPartitionFailover": false - }], - "runtimes": [{ - "id": "sdkDefault", - "gatewayV2": "backendDefault", - "ppcb": "sdkDefault" - }], - "clients": [{ - "id": "sdkDefault", - "binaryEncoding": "sdkDefault", - "routing": "proximity" - }] + "accounts": [ + { + "id": "sessionSingleRegion", + "writeMode": "single", + "consistency": "session", + "regions": [ + { + "name": "East US" + } + ], + "replication": { + "minDelayMs": 0, + "maxDelayMs": 0 + }, + "perPartitionFailover": false + } + ], + "runtimes": [ + { + "id": "sdkDefault", + "gatewayV2": "backendDefault", + "ppcb": "sdkDefault" + } + ], + "clients": [ + { + "id": "sdkDefault", + "binaryEncoding": "sdkDefault", + "routing": "proximity" + } + ] } diff --git a/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json index 465f81b700c..ac3a06b1ac5 100644 --- a/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json +++ b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json @@ -1,39 +1,39 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "bootstrap.primary-success", - "title": "A reachable primary endpoint initializes the client", - "requirement": "Building a client with a reachable account endpoint succeeds and permits a first data operation.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "rust", - "path": "sdk/cosmos/azure_data_cosmos/tests/emulator_tests/cosmos_items.rs", - "test": "item_crud" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "bootstrap.primary-success", + "title": "A reachable primary endpoint initializes the client", + "requirement": "Building a client with a reachable account endpoint succeeds and permits a first data operation.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "rust", + "path": "sdk/cosmos/azure_data_cosmos/tests/emulator_tests/cosmos_items.rs", + "test": "item_crud" + } + ], + "profiles": [ + "hostedEmulatorSmoke" + ], + "tags": [ + "prSmoke", + "bootstrap" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } } - ], - "profiles": [ - "hostedEmulatorSmoke" - ], - "tags": [ - "prSmoke", - "bootstrap" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full" - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "supported", - "fidelity": "full" - } - } } diff --git a/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json index 2b6b7e5a409..07faa111ead 100644 --- a/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json +++ b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json @@ -1,39 +1,39 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "diagnostics.success-and-error", - "title": "Success and error paths expose critical diagnostics", - "requirement": "Public diagnostics identify the logical operation, effective status, activity, attempts, and contacted region on successful and failed operations.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "java", - "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosDiagnosticsE2ETest.java", - "test": "onlyCustomDiagnosticsHandler" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "diagnostics.success-and-error", + "title": "Success and error paths expose critical diagnostics", + "requirement": "Public diagnostics identify the logical operation, effective status, activity, attempts, and contacted region on successful and failed operations.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "java", + "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosDiagnosticsE2ETest.java", + "test": "onlyCustomDiagnosticsHandler" + } + ], + "profiles": [ + "hostedEmulatorSmoke" + ], + "tags": [ + "prSmoke", + "diagnostics" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } } - ], - "profiles": [ - "hostedEmulatorSmoke" - ], - "tags": [ - "prSmoke", - "diagnostics" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full" - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "supported", - "fidelity": "full" - } - } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json index f60e07423d3..b6a1867f55e 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json @@ -1,40 +1,40 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "item.create-conflict", - "title": "Creating a duplicate identity returns conflict", - "requirement": "A duplicate create returns 409 and leaves the original document unchanged.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "python", - "path": "sdk/cosmos/azure-cosmos/tests/test_crud.py", - "test": "duplicate document create" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.create-conflict", + "title": "Creating a duplicate identity returns conflict", + "requirement": "A duplicate create returns 409 and leaves the original document unchanged.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "python", + "path": "sdk/cosmos/azure-cosmos/tests/test_crud.py", + "test": "duplicate document create" + } + ], + "profiles": [ + "hostedEmulatorSmoke" + ], + "tags": [ + "prSmoke", + "item", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } } - ], - "profiles": [ - "hostedEmulatorSmoke" - ], - "tags": [ - "prSmoke", - "item", - "negative" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full" - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "supported", - "fidelity": "full" - } - } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json index 1623067278c..b678beaef89 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json @@ -1,41 +1,41 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "item.lifecycle", - "title": "An item completes its CRUD lifecycle", - "requirement": "Create, read, replace, and delete preserve the addressed item identity and expose stable statuses.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "java", - "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemTest.java", - "test": "createItem/readItem/replaceItem/deleteItem" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.lifecycle", + "title": "An item completes its CRUD lifecycle", + "requirement": "Create, read, replace, and delete preserve the addressed item identity and expose stable statuses.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "java", + "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemTest.java", + "test": "createItem/readItem/replaceItem/deleteItem" + } + ], + "profiles": [ + "hostedEmulatorSmoke", + "lifecycleConsistencyMatrix", + "readConsistencyOverrideMatrix" + ], + "tags": [ + "prSmoke", + "item" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } } - ], - "profiles": [ - "hostedEmulatorSmoke", - "lifecycleConsistencyMatrix", - "readConsistencyOverrideMatrix" - ], - "tags": [ - "prSmoke", - "item" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full" - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "supported", - "fidelity": "full" - } - } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json index 0eea775044d..730b1fa18a3 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json @@ -1,40 +1,40 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "item.not-found-wrong-partition-key", - "title": "Missing identity and wrong partition key return not found", - "requirement": "Point reads do not leak an item across partition-key identities and return 404 without mutation.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "rust", - "path": "sdk/cosmos/azure_data_cosmos_driver/tests/in_memory_emulator_tests/error_cases.rs", - "test": "read_item_not_found" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.not-found-wrong-partition-key", + "title": "Missing identity and wrong partition key return not found", + "requirement": "Point reads do not leak an item across partition-key identities and return 404 without mutation.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "rust", + "path": "sdk/cosmos/azure_data_cosmos_driver/tests/in_memory_emulator_tests/error_cases.rs", + "test": "read_item_not_found" + } + ], + "profiles": [ + "hostedEmulatorSmoke" + ], + "tags": [ + "prSmoke", + "item", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } } - ], - "profiles": [ - "hostedEmulatorSmoke" - ], - "tags": [ - "prSmoke", - "item", - "negative" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full" - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "supported", - "fidelity": "full" - } - } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json index 2ae7aca8287..7659ff765ca 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json @@ -1,40 +1,40 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "item.optimistic-concurrency", - "title": "A stale ETag cannot overwrite a newer item", - "requirement": "If-Match accepts the current ETag, rejects a stale ETag with 412, and preserves the successful update.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "dotnet", - "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", - "test": "ItemRequestOptionAccessConditionTest" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.optimistic-concurrency", + "title": "A stale ETag cannot overwrite a newer item", + "requirement": "If-Match accepts the current ETag, rejects a stale ETag with 412, and preserves the successful update.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "dotnet", + "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", + "test": "ItemRequestOptionAccessConditionTest" + } + ], + "profiles": [ + "hostedEmulatorSmoke" + ], + "tags": [ + "prSmoke", + "item", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } } - ], - "profiles": [ - "hostedEmulatorSmoke" - ], - "tags": [ - "prSmoke", - "item", - "negative" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full" - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "supported", - "fidelity": "full" - } - } } diff --git a/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json index 049f882cc0b..8037eb27473 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json @@ -1,39 +1,39 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "item.upsert-create-update", - "title": "Upsert creates then updates one identity", - "requirement": "Upsert returns 201 for a missing identity and 200 when replacing it, without creating a duplicate.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "dotnet", - "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", - "test": "UpsertItemTest" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.upsert-create-update", + "title": "Upsert creates then updates one identity", + "requirement": "Upsert returns 201 for a missing identity and 200 when replacing it, without creating a duplicate.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "dotnet", + "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", + "test": "UpsertItemTest" + } + ], + "profiles": [ + "hostedEmulatorSmoke" + ], + "tags": [ + "prSmoke", + "item" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } } - ], - "profiles": [ - "hostedEmulatorSmoke" - ], - "tags": [ - "prSmoke", - "item" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full" - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "supported", - "fidelity": "full" - } - } } diff --git a/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json index 8eea2afb579..6bc3f0d0152 100644 --- a/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json +++ b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json @@ -1,44 +1,44 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "management.capabilities", - "title": "The emulator advertises a versioned capability document", - "requirement": "An external SDK runner can discover protocol and management capabilities without using Rust internals.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "rust", - "path": "sdk/cosmos/azure_data_cosmos_emulator/src/management.rs", - "test": "capabilities_report_configured_protocols" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "management.capabilities", + "title": "The emulator advertises a versioned capability document", + "requirement": "An external SDK runner can discover protocol and management capabilities without using Rust internals.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "rust", + "path": "sdk/cosmos/azure_data_cosmos_emulator/src/management.rs", + "test": "capabilities_report_configured_protocols" + } + ], + "profiles": [ + "hostedEmulatorSmoke" + ], + "tags": [ + "prSmoke", + "management" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "capabilities" + ] + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "capabilities", + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "notApplicable", + "fidelity": "none", + "reason": "The capability document is specific to emulator E2E test orchestration." + } } - ], - "profiles": [ - "hostedEmulatorSmoke" - ], - "tags": [ - "prSmoke", - "management" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "capabilities" - ] - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "capabilities", - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "notApplicable", - "fidelity": "none", - "reason": "The capability document is specific to emulator E2E test orchestration." - } - } } diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json index 4c90544fabd..a65734f274d 100644 --- a/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json +++ b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json @@ -1,40 +1,40 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "query.invalid-syntax", - "title": "Invalid query syntax returns bad request", - "requirement": "A deferred query failure is surfaced as a typed 400 error rather than an empty feed.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "dotnet", - "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", - "test": "NegativeQueryTest" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "query.invalid-syntax", + "title": "Invalid query syntax returns bad request", + "requirement": "A deferred query failure is surfaced as a typed 400 error rather than an empty feed.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "dotnet", + "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", + "test": "NegativeQueryTest" + } + ], + "profiles": [ + "hostedEmulatorSmoke" + ], + "tags": [ + "prSmoke", + "query", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } } - ], - "profiles": [ - "hostedEmulatorSmoke" - ], - "tags": [ - "prSmoke", - "query", - "negative" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full" - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "supported", - "fidelity": "full" - } - } } diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json index 8923ae50f2c..836b8224d06 100644 --- a/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json +++ b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json @@ -1,39 +1,39 @@ { - "$schema": "../../schema/scenario.v1.json", - "specVersion": "1.0", - "id": "query.parameterized-filter", - "title": "A parameterized partition query filters and orders results", - "requirement": "Query parameters are bound without string substitution and deterministic ORDER BY results preserve order.", - "maturity": "candidate", - "precedents": [ - { - "sdk": "java", - "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemTest.java", - "test": "queryItems" + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "query.parameterized-filter", + "title": "A parameterized partition query filters and orders results", + "requirement": "Query parameters are bound without string substitution and deterministic ORDER BY results preserve order.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "java", + "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemTest.java", + "test": "queryItems" + } + ], + "profiles": [ + "hostedEmulatorSmoke" + ], + "tags": [ + "prSmoke", + "query" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } } - ], - "profiles": [ - "hostedEmulatorSmoke" - ], - "tags": [ - "prSmoke", - "query" - ], - "backends": { - "hostedEmulatorGatewayV1": { - "applicability": "required", - "fidelity": "full" - }, - "hostedEmulatorGatewayV2": { - "applicability": "required", - "fidelity": "full", - "requires": [ - "gatewayV2" - ] - }, - "azureLive": { - "applicability": "supported", - "fidelity": "full" - } - } } diff --git a/sdk/cosmos/e2e_tests/schema/profile.v1.json b/sdk/cosmos/e2e_tests/schema/profile.v1.json index aaaa075ae35..d2bae51f121 100644 --- a/sdk/cosmos/e2e_tests/schema/profile.v1.json +++ b/sdk/cosmos/e2e_tests/schema/profile.v1.json @@ -26,31 +26,66 @@ "accounts": { "type": "array", "minItems": 1, - "items": { "$ref": "#/$defs/account" } + "items": { + "$ref": "#/$defs/account" + } }, "runtimes": { "type": "array", "minItems": 1, - "items": { "$ref": "#/$defs/runtime" } + "items": { + "$ref": "#/$defs/runtime" + } }, "clients": { "type": "array", "minItems": 1, - "items": { "$ref": "#/$defs/client" } + "items": { + "$ref": "#/$defs/client" + } } }, "$defs": { "readConsistencyStrategy": { - "enum": ["Default", "Eventual", "Session", "LatestCommitted", "GlobalStrong"] + "enum": [ + "Default", + "Eventual", + "Session", + "LatestCommitted", + "GlobalStrong" + ] }, "account": { "type": "object", "additionalProperties": false, - "required": ["id", "writeMode", "consistency", "regions", "replication", "perPartitionFailover"], + "required": [ + "id", + "writeMode", + "consistency", + "regions", + "replication", + "perPartitionFailover" + ], "properties": { - "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" }, - "writeMode": { "enum": ["single", "multi"] }, - "consistency": { "enum": ["strong", "boundedStaleness", "session", "consistentPrefix", "eventual"] }, + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "writeMode": { + "enum": [ + "single", + "multi" + ] + }, + "consistency": { + "enum": [ + "strong", + "boundedStaleness", + "session", + "consistentPrefix", + "eventual" + ] + }, "regions": { "type": "array", "minItems": 1, @@ -58,42 +93,102 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["name"], - "properties": { "name": { "type": "string", "minLength": 1 } } + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } } }, "replication": { "type": "object", "additionalProperties": false, - "required": ["minDelayMs", "maxDelayMs"], + "required": [ + "minDelayMs", + "maxDelayMs" + ], "properties": { - "minDelayMs": { "type": "integer", "minimum": 0 }, - "maxDelayMs": { "type": "integer", "minimum": 0 } + "minDelayMs": { + "type": "integer", + "minimum": 0 + }, + "maxDelayMs": { + "type": "integer", + "minimum": 0 + } } }, - "perPartitionFailover": { "type": "boolean" } + "perPartitionFailover": { + "type": "boolean" + } } }, "runtime": { "type": "object", "additionalProperties": false, - "required": ["id", "gatewayV2", "ppcb"], + "required": [ + "id", + "gatewayV2", + "ppcb" + ], "properties": { - "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" }, - "gatewayV2": { "enum": ["enabled", "disabled", "backendDefault"] }, - "ppcb": { "enum": ["enabled", "disabled", "sdkDefault"] }, - "defaultReadConsistencyStrategy": { "$ref": "#/$defs/readConsistencyStrategy" } + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "gatewayV2": { + "enum": [ + "enabled", + "disabled", + "backendDefault" + ] + }, + "ppcb": { + "enum": [ + "enabled", + "disabled", + "sdkDefault" + ] + }, + "defaultReadConsistencyStrategy": { + "$ref": "#/$defs/readConsistencyStrategy" + } } }, "client": { "type": "object", "additionalProperties": false, - "required": ["id", "binaryEncoding", "routing"], + "required": [ + "id", + "binaryEncoding", + "routing" + ], "properties": { - "id": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" }, - "binaryEncoding": { "enum": ["enabled", "disabled", "sdkDefault"] }, - "routing": { "enum": ["proximity", "preferredRegions", "accountOrder"] }, - "defaultReadConsistencyStrategy": { "$ref": "#/$defs/readConsistencyStrategy" } + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "binaryEncoding": { + "enum": [ + "enabled", + "disabled", + "sdkDefault" + ] + }, + "routing": { + "enum": [ + "proximity", + "preferredRegions", + "accountOrder" + ] + }, + "defaultReadConsistencyStrategy": { + "$ref": "#/$defs/readConsistencyStrategy" + } } } } From 40c98086a4fa866098407c2e3ebc85d6149cebf9 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Thu, 10 Sep 2026 21:17:58 +0000 Subject: [PATCH 04/19] Refactored test cases for better readability --- .../tests/e2e_test_cases/bootstrap_primary.rs | 6 + .../tests/e2e_test_cases/capabilities.rs | 40 +- .../diagnostics_success_and_error.rs | 5 + .../e2e_test_cases/item_create_conflict.rs | 107 +- .../tests/e2e_test_cases/item_lifecycle.rs | 1249 ++++++++++------- .../tests/e2e_test_cases/item_not_found.rs | 63 +- .../item_optimistic_concurrency.rs | 12 +- .../tests/e2e_test_cases/item_upsert.rs | 7 +- .../e2e_test_cases/query_invalid_syntax.rs | 3 + .../query_parameterized_filter.rs | 5 + 10 files changed, 906 insertions(+), 591 deletions(-) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs index 11b1f515838..1a018978b8a 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs @@ -17,12 +17,18 @@ async fn bootstrap_primary_endpoint() -> TestResult { if !should_run("bootstrap.primary-success")? { return Ok(()); } + + // Building the public SDK client against the reachable primary endpoint succeeds. let client = build_client().await?; assert!(hosted_only()); + + // The initialized client can complete its first account operation. let database_id = format!("e2e-bootstrap-{}", azure_core::Uuid::new_v4()); let response = client.create_database(&database_id, None).await?; assert_eq!(response.status().status_code(), StatusCode::Created); response.into_model()?; + + // Remove the resource created only to prove successful bootstrap. client.database_client(&database_id).delete(None).await?; Ok(()) } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs index ded525f2b6f..4470bcd2fc4 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs @@ -5,23 +5,6 @@ use serde::Deserialize; use crate::e2e_test_cases::{fixture::TestResult, support::should_run}; -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CapabilityDocument { - api_version: u32, - emulator_version: String, - protocols: ProtocolCapabilities, - data_plane: Vec, - management_actions: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProtocolCapabilities { - gateway_v1: bool, - gateway_v2: bool, -} - #[tokio::test] #[cfg_attr( not(any(test_category = "emulator_inmemory", test_category = "e2e")), @@ -31,6 +14,8 @@ async fn capability_document_is_versioned() -> TestResult { if !should_run("management.capabilities")? { return Ok(()); } + + // Read the emulator's public management document without using Rust internals. let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; let response = reqwest::Client::new() .get(url::Url::parse(&management_endpoint)?.join("capabilities")?) @@ -38,6 +23,8 @@ async fn capability_document_is_versioned() -> TestResult { .await?; assert_eq!(response.status(), reqwest::StatusCode::OK); let capabilities: CapabilityDocument = serde_json::from_slice(&response.bytes().await?)?; + + // The document is versioned and advertises the minimum E2E orchestration surface. assert_eq!(capabilities.api_version, 1); assert!(!capabilities.emulator_version.is_empty()); assert!(capabilities.protocols.gateway_v1); @@ -46,7 +33,26 @@ async fn capability_document_is_versioned() -> TestResult { .management_actions .iter() .any(|value| value == "partitionSplit")); + + // Gateway V2 advertisement follows the emulator flavor selected by the profile matrix. let expects_v2 = std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR").as_deref() == Ok("inmemory-v2"); assert_eq!(capabilities.protocols.gateway_v2, expects_v2); Ok(()) } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CapabilityDocument { + api_version: u32, + emulator_version: String, + protocols: ProtocolCapabilities, + data_plane: Vec, + management_actions: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolCapabilities { + gateway_v1: bool, + gateway_v2: bool, +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs index ba451216dd6..6b5d99ae2f6 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs @@ -19,10 +19,13 @@ async fn diagnostics_cover_success_and_error() -> TestResult { return Ok(()); } E2eTestFixture::run(async |fixture| { + // Arrange one readable item in East US. fixture .container .create_item("A", "item-1", item("item-1", "A", 1), None) .await?; + + // Success diagnostics identify the operation, 200 status, activity, request, and region. let success = fixture.container.read_item("A", "item-1", None).await?; assert_eq!(success.status().status_code(), StatusCode::Ok); assert_critical_diagnostics(&success.diagnostics(), "read_item", StatusCode::Ok); @@ -30,6 +33,8 @@ async fn diagnostics_cover_success_and_error() -> TestResult { .diagnostics() .regions_contacted() .contains(&Region::EAST_US)); + + // Error diagnostics preserve the same fields while reporting the terminal plain 404. let error = fixture .container .read_item("A", "missing", None) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs index 68c75d48203..ba3ad9210e8 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs @@ -13,55 +13,7 @@ use crate::e2e_test_cases::{ support::{assert_critical_diagnostics, should_run}, }; -struct DuplicateCreateCase { - id: &'static str, - partition_key_definition: PartitionKeyDefinition, - partition_key: PartitionKey, - original: Value, - duplicate: Value, -} - -fn duplicate_create_cases() -> Vec { - let simple = |id, version| DuplicateCreateCase { - id, - partition_key_definition: PartitionKeyDefinition::new(vec!["/pk".into()]) - .with_kind(PartitionKeyKind::Hash) - .with_version(version), - partition_key: PartitionKey::from("A"), - original: json!({ "id": "duplicate-1", "pk": "A", "value": 1 }), - duplicate: json!({ "id": "duplicate-1", "pk": "A", "value": 2 }), - }; - vec![ - simple("hashV1", PartitionKeyVersion::V1), - simple("hashV2", PartitionKeyVersion::V2), - DuplicateCreateCase { - id: "hierarchicalV2", - partition_key_definition: PartitionKeyDefinition::new(vec![ - "/tenant".into(), - "/user".into(), - ]) - .with_kind(PartitionKeyKind::MultiHash) - .with_version(PartitionKeyVersion::V2), - partition_key: PartitionKey::from(vec![ - PartitionKeyValue::from("tenant-a"), - PartitionKeyValue::from("user-1"), - ]), - original: json!({ - "id": "duplicate-1", - "tenant": "tenant-a", - "user": "user-1", - "value": 1 - }), - duplicate: json!({ - "id": "duplicate-1", - "tenant": "tenant-a", - "user": "user-1", - "value": 2 - }), - }, - ] -} - +// Run the same conflict contract for Hash V1, Hash V2, and hierarchical V2 partition keys. #[tokio::test] #[cfg_attr( not(any(test_category = "emulator_inmemory", test_category = "e2e")), @@ -83,6 +35,7 @@ async fn duplicate_create_preserves_original() -> TestResult { ); E2eTestFixture::run_with_partition_key(case.partition_key_definition, async |fixture| { + // Arrange the original value 1 document. fixture .container .create_item( @@ -92,6 +45,8 @@ async fn duplicate_create_preserves_original() -> TestResult { None, ) .await?; + + // Creating value 2 with the same ID and partition key returns conflict. let error = fixture .container .create_item( @@ -118,6 +73,8 @@ async fn duplicate_create_preserves_original() -> TestResult { "create_item", StatusCode::Conflict, ); + + // The failed duplicate create leaves every original field unchanged. let stored: Value = fixture .container .read_item(case.partition_key.clone(), document_id, None) @@ -141,3 +98,55 @@ async fn duplicate_create_preserves_original() -> TestResult { } Ok(()) } + +// Partition-key cases --------------------------------------------------------- + +struct DuplicateCreateCase { + id: &'static str, + partition_key_definition: PartitionKeyDefinition, + partition_key: PartitionKey, + original: Value, + duplicate: Value, +} + +fn duplicate_create_cases() -> Vec { + let simple_hash = |id, version| DuplicateCreateCase { + id, + partition_key_definition: PartitionKeyDefinition::new(vec!["/pk".into()]) + .with_kind(PartitionKeyKind::Hash) + .with_version(version), + partition_key: PartitionKey::from("A"), + original: json!({ "id": "duplicate-1", "pk": "A", "value": 1 }), + duplicate: json!({ "id": "duplicate-1", "pk": "A", "value": 2 }), + }; + + vec![ + simple_hash("hashV1", PartitionKeyVersion::V1), + simple_hash("hashV2", PartitionKeyVersion::V2), + DuplicateCreateCase { + id: "hierarchicalV2", + partition_key_definition: PartitionKeyDefinition::new(vec![ + "/tenant".into(), + "/user".into(), + ]) + .with_kind(PartitionKeyKind::MultiHash) + .with_version(PartitionKeyVersion::V2), + partition_key: PartitionKey::from(vec![ + PartitionKeyValue::from("tenant-a"), + PartitionKeyValue::from("user-1"), + ]), + original: json!({ + "id": "duplicate-1", + "tenant": "tenant-a", + "user": "user-1", + "value": 1 + }), + duplicate: json!({ + "id": "duplicate-1", + "tenant": "tenant-a", + "user": "user-1", + "value": 2 + }), + }, + ] +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index 4e3c036084b..cccea04d3c5 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -1,8 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +use std::time::Duration; + use azure_core::http::StatusCode; use azure_data_cosmos::{ + clients::ContainerClient, options::{ AvailabilityStrategy, ItemReadOptions, OperationOptions, ReadConsistencyStrategy, Region, }, @@ -10,171 +13,659 @@ use azure_data_cosmos::{ }; use crate::e2e_test_cases::{ - catalog::{selected_profile_for, AccountDefinition, ClientDefinition, Profile}, + catalog::{ + selected_profile_for, AccountDefinition, ClientDefinition, Profile, RuntimeDefinition, + }, fixture::{build_client_with_defaults, E2eTestFixture, TestResult}, support::{assert_critical_diagnostics, item, write_options_with_content, Item}, }; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct ExpectedStatus { - status_code: u16, - sub_status_code: Option, +const REPLICATION_TIMEOUT: Duration = Duration::from_secs(5); +const RETRY_DELAY: Duration = Duration::from_millis(50); + +// The JSON profile selects the account, runtime, and client configuration. Rust then expands +// that setup into the operation-level cases below: +// +// * hostedEmulatorSmoke: one account-default read; +// * lifecycleConsistencyMatrix: Default, Eventual, Session, LatestCommitted, and GlobalStrong; +// * readConsistencyOverrideMatrix: inherit defaults, restore account default, and Eventual. +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn item_lifecycle() -> TestResult { + let Some(profile) = selected_profile_for("item.lifecycle")? else { + return Ok(()); + }; + let setup = SelectedLifecycleSetup::from_profile(&profile)?; + let read_cases = read_cases_for_selected_profile(&setup)?; + + for read_case in read_cases { + run_lifecycle_case(&setup, &read_case).await?; + } + Ok(()) } -impl ExpectedStatus { - const fn new(status_code: u16, sub_status_code: Option) -> Self { - Self { - status_code, - sub_status_code, +// This is the lifecycle contract. Keep the operation sequence and its assertions visible here; +// helpers below translate profiles, construct the varying read, and handle polling mechanics. +async fn run_lifecycle_case( + setup: &SelectedLifecycleSetup<'_>, + read_case: &PostCreateReadCase, +) -> TestResult { + let execution = setup.execution_name(read_case.name); + let client = setup.build_client().await?; + + E2eTestFixture::run_with_client(client, "/pk".into(), async |fixture| { + let item_id = format!("lifecycle-{}", execution.replace('/', "-")); + + // Create an item and capture the session token used by explicit Session reads. + let created = fixture + .container + .create_item("A", &item_id, item(&item_id, "A", 1), None) + .await?; + assert_eq!(created.status().status_code(), StatusCode::Created); + assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); + let create_session_token = created + .headers() + .session_token() + .map(|token| token.as_str().to_owned()); + + // Read the created item using this case's operation-level consistency behavior. + let read_outcome = read_created_item( + &fixture.container, + &item_id, + create_session_token, + read_case, + &execution, + ) + .await?; + match read_outcome { + PostCreateReadOutcome::Item(actual) => assert_eq!( + actual, + item(&item_id, "A", 1), + "read returned the wrong item for '{execution}'" + ), + PostCreateReadOutcome::RejectedBeforeTransport => assert_eq!( + read_case.expectation, + ReadExpectation::RejectedBeforeTransport, + "read was rejected unexpectedly for '{execution}'" + ), } - } - fn matches(self, status_code: u16, sub_status_code: Option) -> bool { - self.status_code == status_code - && self - .sub_status_code - .is_none_or(|expected| expected == sub_status_code.unwrap_or(0)) - } + // Replace the item and verify the returned model. + let replaced = fixture + .container + .replace_item( + "A", + &item_id, + item(&item_id, "A", 2), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); + assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); + + // Delete the item, then use the delete session token to verify a plain 404/0. + let deleted = fixture.container.delete_item("A", &item_id, None).await?; + assert_eq!(deleted.status().status_code(), StatusCode::NoContent); + assert_critical_diagnostics(&deleted.diagnostics(), "delete_item", StatusCode::NoContent); + let delete_session_token = deleted + .headers() + .session_token() + .map(|token| token.as_str().to_owned()) + .ok_or("delete response must carry a session token")?; + assert_item_deleted( + &fixture.container, + &item_id, + delete_session_token, + &execution, + ) + .await?; + + Ok(()) + }) + .await } -const PLAIN_NOT_FOUND: ExpectedStatus = ExpectedStatus::new(404, Some(0)); -const SESSION_NOT_AVAILABLE: ExpectedStatus = ExpectedStatus::new(404, Some(1002)); -const READ_SUCCEEDED: ExpectedStatus = ExpectedStatus::new(200, None); -const CLIENT_REJECTED: ExpectedStatus = ExpectedStatus::new(400, None); - -#[derive(Debug)] -struct LifecycleReadCase { - id: String, - operation_strategy: Option<&'static str>, - explicit_session_token: bool, - acceptable_initial_statuses: &'static [ExpectedStatus], - terminal_status: ExpectedStatus, - max_wait_ms: Option, +// Operation cases ------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionTokenBehavior { + SdkManaged, + ExplicitCreateResponse, + Omitted, } -impl LifecycleReadCase { - fn succeeds( - id: impl Into, - operation_strategy: Option<&'static str>, - explicit_session_token: bool, - acceptable_initial_statuses: &'static [ExpectedStatus], - ) -> Self { - Self { - id: id.into(), - operation_strategy, - explicit_session_token, - acceptable_initial_statuses, - terminal_status: READ_SUCCEEDED, - max_wait_ms: (!acceptable_initial_statuses.is_empty()).then_some(5_000), - } - } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ReadExpectation { + SucceedsImmediately, + EventuallySucceeds { + allowed_transient_statuses: &'static [TransientReadStatus], + }, + RejectedBeforeTransport, +} - fn rejects(id: impl Into, operation_strategy: &'static str) -> Self { - Self { - id: id.into(), - operation_strategy: Some(operation_strategy), - explicit_session_token: false, - acceptable_initial_statuses: &[], - terminal_status: CLIENT_REJECTED, - max_wait_ms: None, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TransientReadStatus { + PlainNotFound, + SessionNotAvailable, +} + +impl TransientReadStatus { + const fn http_status(self) -> ExpectedHttpStatus { + match self { + Self::PlainNotFound => PLAIN_NOT_FOUND, + Self::SessionNotAvailable => SESSION_NOT_AVAILABLE, } } +} - fn is_terminal(&self, status_code: u16, sub_status_code: Option) -> bool { - self.terminal_status.matches(status_code, sub_status_code) - } +#[derive(Debug, PartialEq, Eq)] +enum PostCreateReadOutcome { + Item(Item), + RejectedBeforeTransport, +} - fn is_acceptable_initial(&self, status_code: u16, sub_status_code: Option) -> bool { - self.acceptable_initial_statuses - .iter() - .any(|expected| expected.matches(status_code, sub_status_code)) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PostCreateReadCase { + name: &'static str, + consistency_override: Option, + session_token: SessionTokenBehavior, + expectation: ReadExpectation, +} + +impl PostCreateReadCase { + const fn new( + name: &'static str, + consistency_override: Option, + session_token: SessionTokenBehavior, + expectation: ReadExpectation, + ) -> Self { + Self { + name, + consistency_override, + session_token, + expectation, + } } } -fn lifecycle_cases( - profile: &Profile, - account: &AccountDefinition, - runtime_default: Option<&str>, - client_default: Option<&str>, -) -> TestResult> { - match profile.id.as_str() { - "hostedEmulatorSmoke" => Ok(vec![LifecycleReadCase::succeeds( - "hostedSmokeDefault", - Some("Default"), - false, - &[], +fn read_cases_for_selected_profile( + setup: &SelectedLifecycleSetup<'_>, +) -> TestResult> { + match setup.profile.id.as_str() { + "hostedEmulatorSmoke" => Ok(vec![PostCreateReadCase::new( + "default_strategy_reads_created_item", + Some(ReadConsistencyStrategy::Default), + SessionTokenBehavior::SdkManaged, + ReadExpectation::SucceedsImmediately, )]), - "lifecycleConsistencyMatrix" => Ok(consistency_cases(account)), - "readConsistencyOverrideMatrix" => Ok(override_cases(runtime_default, client_default)), + "lifecycleConsistencyMatrix" => Ok(read_cases_for_account_consistency(setup.account)), + "readConsistencyOverrideMatrix" => Ok(read_cases_for_default_precedence( + setup.account, + setup.runtime.default_read_consistency_strategy.as_deref(), + setup.client.default_read_consistency_strategy.as_deref(), + )?), profile => Err(format!("item.lifecycle does not implement profile '{profile}'").into()), } } -fn consistency_cases(account: &AccountDefinition) -> Vec { - let strong = account.consistency == "strong"; - let account_session = account.consistency == "session"; - [ - ("Default", account_session, false), - ("Eventual", false, false), - ("Session", true, true), - ("LatestCommitted", false, false), - ("GlobalStrong", false, false), +fn read_cases_for_account_consistency(account: &AccountDefinition) -> Vec { + let is_strong_account = account.consistency == "strong"; + + let regional_read = if is_strong_account { + ReadExpectation::SucceedsImmediately + } else { + eventually_succeeds_after(TransientReadStatus::PlainNotFound) + }; + let session_read = if is_strong_account { + ReadExpectation::SucceedsImmediately + } else { + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) + }; + let account_default_read = match account.consistency.as_str() { + "strong" => ReadExpectation::SucceedsImmediately, + "session" => session_read, + _ => regional_read, + }; + let account_default_token = if account.consistency == "session" { + SessionTokenBehavior::SdkManaged + } else { + SessionTokenBehavior::Omitted + }; + let global_strong_read = if is_strong_account { + ReadExpectation::SucceedsImmediately + } else { + ReadExpectation::RejectedBeforeTransport + }; + + vec![ + PostCreateReadCase::new( + "default_strategy_uses_account_consistency", + Some(ReadConsistencyStrategy::Default), + account_default_token, + account_default_read, + ), + PostCreateReadCase::new( + "eventual_strategy_allows_replication_lag", + Some(ReadConsistencyStrategy::Eventual), + SessionTokenBehavior::Omitted, + regional_read, + ), + PostCreateReadCase::new( + "session_strategy_uses_create_token", + Some(ReadConsistencyStrategy::Session), + SessionTokenBehavior::ExplicitCreateResponse, + session_read, + ), + PostCreateReadCase::new( + "latest_committed_is_region_local", + Some(ReadConsistencyStrategy::LatestCommitted), + SessionTokenBehavior::Omitted, + regional_read, + ), + PostCreateReadCase::new( + "global_strong_requires_strong_account", + Some(ReadConsistencyStrategy::GlobalStrong), + SessionTokenBehavior::Omitted, + global_strong_read, + ), ] - .into_iter() - .map(|(strategy, session_read, explicit_session_token)| { - let id = format!("{}/{}", account.id, strategy); - if strategy == "GlobalStrong" && !strong { - LifecycleReadCase::rejects(id, strategy) - } else if strong { - LifecycleReadCase::succeeds(id, Some(strategy), explicit_session_token, &[]) - } else if session_read { - LifecycleReadCase::succeeds( - id, - Some(strategy), - explicit_session_token, - &[SESSION_NOT_AVAILABLE], - ) - } else { - LifecycleReadCase::succeeds( - id, - Some(strategy), - explicit_session_token, - &[PLAIN_NOT_FOUND], - ) - } - }) - .collect() } -fn override_cases( +fn read_cases_for_default_precedence( + account: &AccountDefinition, runtime_default: Option<&str>, client_default: Option<&str>, -) -> Vec { - let inherited_session = client_default - .or(runtime_default) - .is_none_or(|strategy| strategy == "Session"); - let inherited_statuses: &'static [ExpectedStatus] = if inherited_session { - &[SESSION_NOT_AVAILABLE] +) -> TestResult> { + if account.consistency != "session" { + return Err(format!( + "readConsistencyOverrideMatrix requires a Session account, got '{}'", + account.consistency + ) + .into()); + } + // Effective precedence is operation > client > runtime > account. These cases vary only the + // operation value; the JSON profile supplies the selected client and runtime defaults. + let inherited_uses_session = match client_default.or(runtime_default) { + Some(strategy) => parse_read_consistency(strategy)? == ReadConsistencyStrategy::Session, + None => account.consistency == "session", + }; + let inherited_expectation = if inherited_uses_session { + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) } else { - &[PLAIN_NOT_FOUND] + eventually_succeeds_after(TransientReadStatus::PlainNotFound) }; - vec![ - LifecycleReadCase::succeeds("override/Inherit", None, false, inherited_statuses), - LifecycleReadCase::succeeds( - "override/Default", - Some("Default"), - true, - &[SESSION_NOT_AVAILABLE], + let inherited_token = if inherited_uses_session { + SessionTokenBehavior::SdkManaged + } else { + SessionTokenBehavior::Omitted + }; + + Ok(vec![ + PostCreateReadCase::new( + "inherits_client_then_runtime_then_account_default", + None, + inherited_token, + inherited_expectation, ), - LifecycleReadCase::succeeds( - "override/Eventual", - Some("Eventual"), - false, - &[PLAIN_NOT_FOUND], + PostCreateReadCase::new( + "default_override_restores_account_consistency", + Some(ReadConsistencyStrategy::Default), + SessionTokenBehavior::ExplicitCreateResponse, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), ), - ] + PostCreateReadCase::new( + "eventual_override_wins_over_all_defaults", + Some(ReadConsistencyStrategy::Eventual), + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), + ), + ]) +} + +fn eventually_succeeds_after(status: TransientReadStatus) -> ReadExpectation { + ReadExpectation::EventuallySucceeds { + allowed_transient_statuses: match status { + TransientReadStatus::PlainNotFound => &[TransientReadStatus::PlainNotFound], + TransientReadStatus::SessionNotAvailable => &[TransientReadStatus::SessionNotAvailable], + }, + } } +// Selected JSON setup --------------------------------------------------------- + +struct SelectedLifecycleSetup<'a> { + profile: &'a Profile, + account: &'a AccountDefinition, + runtime: &'a RuntimeDefinition, + client: &'a ClientDefinition, + routing: RoutingStrategy, +} + +impl<'a> SelectedLifecycleSetup<'a> { + fn from_profile(profile: &'a Profile) -> TestResult { + let account_ids: Vec<_> = profile + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let runtime_ids: Vec<_> = profile + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let client_ids: Vec<_> = profile + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + + let account = profile.account(selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &account_ids)?); + let runtime = profile.runtime(selected_axis("AZURE_COSMOS_E2E_RUNTIME", &runtime_ids)?); + let client = profile.client(selected_axis("AZURE_COSMOS_E2E_CLIENT", &client_ids)?); + let read_region = lifecycle_read_region(profile)?; + let routing = lifecycle_routing(client, &read_region)?; + + Ok(Self { + profile, + account, + runtime, + client, + routing, + }) + } + + async fn build_client(&self) -> TestResult { + build_client_with_defaults( + self.routing.clone(), + parse_optional_read_consistency( + self.runtime.default_read_consistency_strategy.as_deref(), + )?, + parse_optional_read_consistency( + self.client.default_read_consistency_strategy.as_deref(), + )?, + parse_setup_switch(&self.runtime.gateway_v2, "backendDefault")?, + parse_setup_switch(&self.runtime.ppcb, "sdkDefault")?, + parse_setup_switch(&self.client.binary_encoding, "sdkDefault")?, + ) + .await + } + + fn execution_name(&self, case_name: &str) -> String { + format!( + "{}/{}/{}/{}/{}", + self.profile.id, self.account.id, self.runtime.id, self.client.id, case_name + ) + } +} + +// Retry mechanics ------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExpectedSubstatus { + Any, + ZeroOrMissing, + Exact(u16), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ExpectedHttpStatus { + status_code: StatusCode, + substatus: ExpectedSubstatus, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ActualHttpStatus { + status_code: StatusCode, + substatus: Option, +} + +impl ExpectedHttpStatus { + fn matches(self, status_code: StatusCode, substatus: Option) -> bool { + self.status_code == status_code + && match self.substatus { + ExpectedSubstatus::Any => true, + ExpectedSubstatus::ZeroOrMissing => { + substatus.is_none() || matches!(substatus, Some(0)) + } + ExpectedSubstatus::Exact(expected) => { + matches!(substatus, Some(actual) if actual == expected) + } + } + } +} + +const PLAIN_NOT_FOUND: ExpectedHttpStatus = ExpectedHttpStatus { + status_code: StatusCode::NotFound, + substatus: ExpectedSubstatus::ZeroOrMissing, +}; +const SESSION_NOT_AVAILABLE: ExpectedHttpStatus = ExpectedHttpStatus { + status_code: StatusCode::NotFound, + substatus: ExpectedSubstatus::Exact(1002), +}; +const READ_SUCCEEDED: ExpectedHttpStatus = ExpectedHttpStatus { + status_code: StatusCode::Ok, + substatus: ExpectedSubstatus::Any, +}; +const CLIENT_REJECTED: ExpectedHttpStatus = ExpectedHttpStatus { + status_code: StatusCode::BadRequest, + substatus: ExpectedSubstatus::Any, +}; + +async fn read_created_item( + container: &ContainerClient, + item_id: &str, + create_session_token: Option, + read_case: &PostCreateReadCase, + execution: &str, +) -> TestResult { + let mut operation = OperationOptions::default(); + operation.read_consistency_strategy = read_case.consistency_override; + operation.availability_strategy = Some(AvailabilityStrategy::Disabled); + let mut options = ItemReadOptions::default().with_operation_options(operation); + if read_case.session_token == SessionTokenBehavior::ExplicitCreateResponse { + options = options.with_session_token( + create_session_token.ok_or("create response must carry a session token")?, + ); + } + + let deadline = tokio::time::Instant::now() + read_case.expectation.timeout(); + let mut observed_statuses = Vec::new(); + loop { + match container + .read_item("A", item_id, Some(options.clone())) + .await + { + Ok(response) => { + let status = ActualHttpStatus { + status_code: response.status().status_code(), + substatus: response.status().sub_status().map(|value| value.value()), + }; + record_request_statuses(&response.diagnostics(), &mut observed_statuses); + observed_statuses.push(status); + if read_case + .expectation + .terminal_status() + .matches(status.status_code, status.substatus) + { + assert_critical_diagnostics( + &response.diagnostics(), + "read_item", + StatusCode::Ok, + ); + return Ok(PostCreateReadOutcome::Item(response.into_model::()?)); + } + verify_transient_status( + read_case, + status, + deadline, + execution, + &observed_statuses, + )?; + } + Err(error) => { + let status = ActualHttpStatus { + status_code: error.status().status_code(), + substatus: error.status().sub_status().map(|value| value.value()), + }; + if let Some(diagnostics) = error.diagnostics() { + record_request_statuses(&diagnostics, &mut observed_statuses); + } + observed_statuses.push(status); + if read_case + .expectation + .terminal_status() + .matches(status.status_code, status.substatus) + { + if read_case.expectation == ReadExpectation::RejectedBeforeTransport { + assert!( + error.response().is_none(), + "client validation for '{execution}' unexpectedly received a response" + ); + let diagnostics = error + .diagnostics() + .expect("client rejection must carry diagnostics"); + assert_eq!( + diagnostics.request_count(), + 0, + "client validation must reject '{execution}' before transport" + ); + } + return Ok(PostCreateReadOutcome::RejectedBeforeTransport); + } + verify_transient_status( + read_case, + status, + deadline, + execution, + &observed_statuses, + )?; + } + } + tokio::time::sleep(RETRY_DELAY).await; + } +} + +async fn assert_item_deleted( + container: &ContainerClient, + item_id: &str, + delete_session_token: String, + execution: &str, +) -> TestResult { + let mut operation = OperationOptions::default(); + operation.read_consistency_strategy = Some(ReadConsistencyStrategy::Session); + operation.availability_strategy = Some(AvailabilityStrategy::Disabled); + let options = ItemReadOptions::default() + .with_operation_options(operation) + .with_session_token(delete_session_token); + let deadline = tokio::time::Instant::now() + REPLICATION_TIMEOUT; + + loop { + match container + .read_item("A", item_id, Some(options.clone())) + .await + { + Err(error) + if PLAIN_NOT_FOUND.matches( + error.status().status_code(), + error.status().sub_status().map(|value| value.value()), + ) => + { + return Ok(()); + } + Err(error) + if SESSION_NOT_AVAILABLE.matches( + error.status().status_code(), + error.status().sub_status().map(|value| value.value()), + ) && tokio::time::Instant::now() < deadline => {} + Ok(_) if tokio::time::Instant::now() < deadline => {} + Ok(_) => { + return Err(format!( + "deleted item for '{execution}' remained visible after {REPLICATION_TIMEOUT:?}" + ) + .into()) + } + Err(error) => return Err(error.into()), + } + tokio::time::sleep(RETRY_DELAY).await; + } +} + +impl ReadExpectation { + const fn terminal_status(self) -> ExpectedHttpStatus { + match self { + Self::SucceedsImmediately | Self::EventuallySucceeds { .. } => READ_SUCCEEDED, + Self::RejectedBeforeTransport => CLIENT_REJECTED, + } + } + + const fn allowed_transient_statuses(self) -> &'static [TransientReadStatus] { + match self { + Self::EventuallySucceeds { + allowed_transient_statuses, + } => allowed_transient_statuses, + Self::SucceedsImmediately | Self::RejectedBeforeTransport => &[], + } + } + + const fn timeout(self) -> Duration { + match self { + Self::EventuallySucceeds { .. } => REPLICATION_TIMEOUT, + Self::SucceedsImmediately | Self::RejectedBeforeTransport => Duration::ZERO, + } + } +} + +fn verify_transient_status( + read_case: &PostCreateReadCase, + actual: ActualHttpStatus, + deadline: tokio::time::Instant, + execution: &str, + observed_statuses: &[ActualHttpStatus], +) -> TestResult { + let allowed = read_case.expectation.allowed_transient_statuses(); + if !allowed.iter().any(|expected| { + expected + .http_status() + .matches(actual.status_code, actual.substatus) + }) { + return Err(format!( + "'{execution}' observed unexpected read status {actual:?}; expected transient {allowed:?} or terminal {:?}; observed {observed_statuses:?}", + read_case.expectation.terminal_status() + ) + .into()); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "'{execution}' did not reach terminal status {:?} within {:?}; observed {observed_statuses:?}", + read_case.expectation.terminal_status(), + read_case.expectation.timeout() + ) + .into()); + } + Ok(()) +} + +fn record_request_statuses( + diagnostics: &azure_data_cosmos::diagnostics::DiagnosticsContext, + observed: &mut Vec, +) { + observed.extend( + diagnostics + .requests() + .iter() + .map(|request| ActualHttpStatus { + status_code: request.status().status_code(), + substatus: request.status().sub_status().map(|value| value.value()), + }), + ); +} + +// Profile value translation --------------------------------------------------- + fn lifecycle_read_region(profile: &Profile) -> TestResult { match profile.id.as_str() { "hostedEmulatorSmoke" => Ok(Region::EAST_US), @@ -220,11 +711,14 @@ fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> TestR } } -fn parse_optional_strategy(value: Option<&str>) -> TestResult> { - value - .map(str::parse::) - .transpose() - .map_err(Into::into) +fn parse_optional_read_consistency( + value: Option<&str>, +) -> TestResult> { + value.map(parse_read_consistency).transpose() +} + +fn parse_read_consistency(value: &str) -> TestResult { + value.parse::().map_err(Into::into) } fn parse_setup_switch(value: &str, default: &str) -> TestResult> { @@ -236,427 +730,188 @@ fn parse_setup_switch(value: &str, default: &str) -> TestResult> { } } -#[tokio::test] -#[cfg_attr( - not(any(test_category = "emulator_inmemory", test_category = "e2e")), - ignore = "requires the externally hosted in-memory emulator" -)] -async fn item_lifecycle() -> TestResult { - let Some(profile) = selected_profile_for("item.lifecycle")? else { - return Ok(()); - }; - run_lifecycle_consistency_matrix(&profile).await -} - -async fn run_lifecycle_consistency_matrix(profile: &Profile) -> TestResult { - let account_ids: Vec<_> = profile - .accounts - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let runtime_ids: Vec<_> = profile - .runtimes - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let client_ids: Vec<_> = profile - .clients - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let account_id = selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &account_ids)?; - let runtime_id = selected_axis("AZURE_COSMOS_E2E_RUNTIME", &runtime_ids)?; - let client_id = selected_axis("AZURE_COSMOS_E2E_CLIENT", &client_ids)?; - let account = profile.account(account_id); - let runtime = profile.runtime(runtime_id); - let client_definition = profile.client(client_id); - let runtime_strategy = - parse_optional_strategy(runtime.default_read_consistency_strategy.as_deref())?; - let client_strategy = parse_optional_strategy( - client_definition - .default_read_consistency_strategy - .as_deref(), - )?; - let cases = lifecycle_cases( - profile, - account, - runtime.default_read_consistency_strategy.as_deref(), - client_definition - .default_read_consistency_strategy - .as_deref(), - )?; - let read_region = lifecycle_read_region(profile)?; - let routing = lifecycle_routing(client_definition, &read_region)?; - let gateway_v2_enabled = parse_setup_switch(&runtime.gateway_v2, "backendDefault")?; - let ppcb_enabled = parse_setup_switch(&runtime.ppcb, "sdkDefault")?; - let binary_encoding_enabled = - parse_setup_switch(&client_definition.binary_encoding, "sdkDefault")?; - - for case in cases { - let client = build_client_with_defaults( - routing.clone(), - runtime_strategy, - client_strategy, - gateway_v2_enabled, - ppcb_enabled, - binary_encoding_enabled, - ) - .await?; - - E2eTestFixture::run_with_client(client, "/pk".into(), async |fixture| { - let item_id = format!("lifecycle-{}", case.id.replace('/', "-")); - let created = fixture - .container - .create_item("A", &item_id, item(&item_id, "A", 1), None) - .await?; - assert_eq!(created.status().status_code(), StatusCode::Created); - assert_critical_diagnostics( - &created.diagnostics(), - "create_item", - StatusCode::Created, - ); - let create_token = created.headers().session_token().cloned(); - - let mut operation = OperationOptions::default(); - operation.read_consistency_strategy = parse_optional_strategy(case.operation_strategy)?; - operation.availability_strategy = Some(AvailabilityStrategy::Disabled); - let mut read_options = ItemReadOptions::default().with_operation_options(operation); - let mut terminal_item = None; - if case.explicit_session_token { - read_options = read_options.with_session_token( - create_token - .clone() - .ok_or("create response must carry a session token")?, - ); - } - - let deadline = tokio::time::Instant::now() - + std::time::Duration::from_millis(case.max_wait_ms.unwrap_or_default()); - let mut observed_statuses = Vec::new(); - loop { - let (status_code, sub_status_code, terminal) = match fixture - .container - .read_item("A", &item_id, Some(read_options.clone())) - .await - { - Ok(read) => { - let status_code = u16::from(read.status().status_code()); - let sub_status_code = - read.status().sub_status().map(|value| value.value()); - let terminal = case.is_terminal(status_code, sub_status_code); - for request in read.diagnostics().requests().iter() { - let request_status = u16::from(request.status().status_code()); - let request_sub_status = - request.status().sub_status().map(|value| value.value()); - if case.is_acceptable_initial(request_status, request_sub_status) { - observed_statuses - .push((request_status, request_sub_status.unwrap_or(0))); - } - } - if terminal { - assert_critical_diagnostics( - &read.diagnostics(), - "read_item", - StatusCode::Ok, - ); - terminal_item = Some(read.into_model::()?); - } - (status_code, sub_status_code, terminal) - } - Err(error) => { - let status_code = u16::from(error.status().status_code()); - let sub_status_code = - error.status().sub_status().map(|value| value.value()); - let terminal = case.is_terminal(status_code, sub_status_code); - if let Some(diagnostics) = error.diagnostics() { - if terminal && case.terminal_status == CLIENT_REJECTED { - assert_eq!( - diagnostics.request_count(), - 0, - "client validation must reject '{}' before transport", - case.id - ); - } - for request in diagnostics.requests().iter() { - let request_status = u16::from(request.status().status_code()); - let request_sub_status = - request.status().sub_status().map(|value| value.value()); - if case.is_acceptable_initial(request_status, request_sub_status) { - observed_statuses - .push((request_status, request_sub_status.unwrap_or(0))); - } - } - } - (status_code, sub_status_code, terminal) - } - }; - observed_statuses.push((status_code, sub_status_code.unwrap_or(0))); - if terminal { - break; - } - if !case.is_acceptable_initial(status_code, sub_status_code) { - return Err(format!( - "execution '{}' observed unexpected read status {status_code}/{}; expected transient {:?} or terminal {:?}; observed {observed_statuses:?}", - case.id, - sub_status_code.unwrap_or(0), - case.acceptable_initial_statuses, - case.terminal_status, - ) - .into()); - } - if tokio::time::Instant::now() >= deadline { - return Err(format!( - "execution '{}' did not reach terminal status {:?} within {} ms; observed {observed_statuses:?}", - case.id, - case.terminal_status, - case.max_wait_ms.unwrap_or_default(), - ) - .into()); - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - - if case.terminal_status == READ_SUCCEEDED { - assert_eq!( - terminal_item, - Some(item(&item_id, "A", 1)), - "terminal read for '{}' returned the wrong item", - case.id - ); - } else { - assert!(terminal_item.is_none()); - } - let replaced = fixture - .container - .replace_item( - "A", - &item_id, - item(&item_id, "A", 2), - Some(write_options_with_content()), - ) - .await?; - assert_eq!(replaced.status().status_code(), StatusCode::Ok); - assert_critical_diagnostics( - &replaced.diagnostics(), - "replace_item", - StatusCode::Ok, - ); - assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); - let deleted = fixture.container.delete_item("A", &item_id, None).await?; - assert_eq!(deleted.status().status_code(), StatusCode::NoContent); - assert_critical_diagnostics( - &deleted.diagnostics(), - "delete_item", - StatusCode::NoContent, - ); - let delete_token = deleted - .headers() - .session_token() - .cloned() - .ok_or("delete response must carry a session token")?; - let mut delete_read_operation = OperationOptions::default(); - delete_read_operation.read_consistency_strategy = - Some(ReadConsistencyStrategy::Session); - delete_read_operation.availability_strategy = Some(AvailabilityStrategy::Disabled); - let delete_read_options = ItemReadOptions::default() - .with_operation_options(delete_read_operation) - .with_session_token(delete_token); - - let delete_deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - match fixture - .container - .read_item("A", &item_id, Some(delete_read_options.clone())) - .await - { - Err(error) - if PLAIN_NOT_FOUND.matches( - u16::from(error.status().status_code()), - error.status().sub_status().map(|value| value.value()), - ) => - { - break; - } - Err(error) - if SESSION_NOT_AVAILABLE.matches( - u16::from(error.status().status_code()), - error.status().sub_status().map(|value| value.value()), - ) && tokio::time::Instant::now() < delete_deadline => - { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - Ok(_) if tokio::time::Instant::now() < delete_deadline => { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - Ok(_) => { - return Err(format!( - "deleted item for '{}' remained visible after 5 seconds", - case.id - ) - .into()) - } - Err(error) => return Err(error.into()), - } - } - Ok(()) - }) - .await?; - } - Ok(()) -} - #[cfg(test)] mod tests { - use super::{ - consistency_cases, override_cases, ExpectedStatus, CLIENT_REJECTED, PLAIN_NOT_FOUND, - READ_SUCCEEDED, SESSION_NOT_AVAILABLE, - }; - - #[test] - fn status_matching_normalizes_missing_substatus_to_zero() { - assert!(PLAIN_NOT_FOUND.matches(404, None)); - assert!(PLAIN_NOT_FOUND.matches(404, Some(0))); - assert!(!PLAIN_NOT_FOUND.matches(404, Some(1002))); - assert!(READ_SUCCEEDED.matches(200, Some(0))); - assert!(!ExpectedStatus::new(200, None).matches(404, None)); - } + use super::*; #[test] - fn account_consistency_matrix_covers_all_operation_strategies() { - let profile = serde_json::from_str::(include_str!( + fn account_consistency_cases_cover_every_account_and_strategy() { + let profile = serde_json::from_str::(include_str!( "../../../e2e_tests/profiles/lifecycleConsistencyMatrix.json" )) .expect("consistency profile must deserialize"); - let expectations = [ - ("strong", [None, None, None, None, None]), + let immediate = ReadExpectation::SucceedsImmediately; + let plain_not_found = eventually_succeeds_after(TransientReadStatus::PlainNotFound); + let session_not_available = + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable); + let rejected = ReadExpectation::RejectedBeforeTransport; + let expected_by_account = [ + ("strong", [immediate; 5]), ( "boundedStaleness", [ - Some(PLAIN_NOT_FOUND), - Some(PLAIN_NOT_FOUND), - Some(SESSION_NOT_AVAILABLE), - Some(PLAIN_NOT_FOUND), - None, + plain_not_found, + plain_not_found, + session_not_available, + plain_not_found, + rejected, ], ), ( "session", [ - Some(SESSION_NOT_AVAILABLE), - Some(PLAIN_NOT_FOUND), - Some(SESSION_NOT_AVAILABLE), - Some(PLAIN_NOT_FOUND), - None, + session_not_available, + plain_not_found, + session_not_available, + plain_not_found, + rejected, ], ), ( "consistentPrefix", [ - Some(PLAIN_NOT_FOUND), - Some(PLAIN_NOT_FOUND), - Some(SESSION_NOT_AVAILABLE), - Some(PLAIN_NOT_FOUND), - None, + plain_not_found, + plain_not_found, + session_not_available, + plain_not_found, + rejected, ], ), ( "eventual", [ - Some(PLAIN_NOT_FOUND), - Some(PLAIN_NOT_FOUND), - Some(SESSION_NOT_AVAILABLE), - Some(PLAIN_NOT_FOUND), - None, + plain_not_found, + plain_not_found, + session_not_available, + plain_not_found, + rejected, ], ), ]; - for (account, transient_statuses) in expectations { - let cases = consistency_cases(profile.account(account)); + for (account, expected) in expected_by_account { + let cases = read_cases_for_account_consistency(profile.account(account)); + assert_eq!( + cases.iter().map(|case| case.name).collect::>(), + [ + "default_strategy_uses_account_consistency", + "eventual_strategy_allows_replication_lag", + "session_strategy_uses_create_token", + "latest_committed_is_region_local", + "global_strong_requires_strong_account", + ], + "case names for {account}" + ); assert_eq!( cases .iter() - .map(|case| case.operation_strategy) + .map(|case| case.consistency_override) .collect::>(), [ - Some("Default"), - Some("Eventual"), - Some("Session"), - Some("LatestCommitted"), - Some("GlobalStrong"), - ] + Some(ReadConsistencyStrategy::Default), + Some(ReadConsistencyStrategy::Eventual), + Some(ReadConsistencyStrategy::Session), + Some(ReadConsistencyStrategy::LatestCommitted), + Some(ReadConsistencyStrategy::GlobalStrong), + ], + "operation consistency overrides for {account}" ); assert_eq!( cases .iter() - .map(|case| case.explicit_session_token) + .map(|case| case.expectation) .collect::>(), - [false, false, true, false, false] + expected, + "read expectations for {account}" + ); + assert_eq!( + cases + .iter() + .map(|case| case.session_token) + .collect::>(), + [ + if account == "session" { + SessionTokenBehavior::SdkManaged + } else { + SessionTokenBehavior::Omitted + }, + SessionTokenBehavior::Omitted, + SessionTokenBehavior::ExplicitCreateResponse, + SessionTokenBehavior::Omitted, + SessionTokenBehavior::Omitted, + ], + "session-token behavior for {account}" ); - for (index, expected_transient) in transient_statuses.into_iter().enumerate() { - assert_eq!( - cases[index].acceptable_initial_statuses, - expected_transient.as_slice() - ); - let terminal = if index == 4 && account != "strong" { - CLIENT_REJECTED - } else { - READ_SUCCEEDED - }; - assert_eq!(cases[index].terminal_status, terminal); - assert_eq!(cases[index].max_wait_ms, expected_transient.map(|_| 5_000)); - } } } #[test] - fn override_matrix_keeps_operation_cases_in_source() { - let profile = serde_json::from_str::(include_str!( + fn override_cases_show_client_runtime_account_precedence() { + let profile = serde_json::from_str::(include_str!( "../../../e2e_tests/profiles/readConsistencyOverrideMatrix.json" )) .expect("override profile must deserialize"); + let account = profile.account("session"); for runtime in &profile.runtimes { for client in &profile.clients { - let cases = override_cases( - runtime.default_read_consistency_strategy.as_deref(), - client.default_read_consistency_strategy.as_deref(), - ); - assert_eq!( - cases - .iter() - .map(|case| case.operation_strategy) - .collect::>(), - [None, Some("Default"), Some("Eventual")] - ); - assert_eq!( - cases - .iter() - .map(|case| case.explicit_session_token) - .collect::>(), - [false, true, false] - ); - let inherited = if client + let inherited_uses_session = client .default_read_consistency_strategy .as_deref() .or(runtime.default_read_consistency_strategy.as_deref()) - .is_none_or(|strategy| strategy == "Session") - { - SESSION_NOT_AVAILABLE + .is_none_or(|strategy| strategy == "Session"); + let inherited_expectation = if inherited_uses_session { + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) + } else { + eventually_succeeds_after(TransientReadStatus::PlainNotFound) + }; + let inherited_token = if inherited_uses_session { + SessionTokenBehavior::SdkManaged } else { - PLAIN_NOT_FOUND + SessionTokenBehavior::Omitted }; - assert_eq!(cases[0].acceptable_initial_statuses, [inherited]); + + let actual = read_cases_for_default_precedence( + account, + runtime.default_read_consistency_strategy.as_deref(), + client.default_read_consistency_strategy.as_deref(), + ) + .expect("profile defaults must be valid"); + let expected = [ + PostCreateReadCase::new( + "inherits_client_then_runtime_then_account_default", + None, + inherited_token, + inherited_expectation, + ), + PostCreateReadCase::new( + "default_override_restores_account_consistency", + Some(ReadConsistencyStrategy::Default), + SessionTokenBehavior::ExplicitCreateResponse, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), + ), + PostCreateReadCase::new( + "eventual_override_wins_over_all_defaults", + Some(ReadConsistencyStrategy::Eventual), + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), + ), + ]; assert_eq!( - cases[1].acceptable_initial_statuses, - [SESSION_NOT_AVAILABLE] + actual, expected, + "runtime '{}' and client '{}'", + runtime.id, client.id ); - assert_eq!(cases[2].acceptable_initial_statuses, [PLAIN_NOT_FOUND]); - assert!(cases.iter().all(|case| { - case.terminal_status == READ_SUCCEEDED && case.max_wait_ms == Some(5_000) - })); } } } + + #[test] + fn status_matching_distinguishes_wildcard_zero_and_exact_substatus() { + assert!(READ_SUCCEEDED.matches(StatusCode::Ok, Some(42))); + assert!(PLAIN_NOT_FOUND.matches(StatusCode::NotFound, None)); + assert!(PLAIN_NOT_FOUND.matches(StatusCode::NotFound, Some(0))); + assert!(!PLAIN_NOT_FOUND.matches(StatusCode::NotFound, Some(1002))); + assert!(SESSION_NOT_AVAILABLE.matches(StatusCode::NotFound, Some(1002))); + } } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs index 776798256a7..2e6e9f0599b 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs @@ -18,36 +18,51 @@ async fn not_found_does_not_cross_partition_keys() -> TestResult { return Ok(()); } E2eTestFixture::run(async |fixture| { + // Arrange one item in logical partition A. fixture .container .create_item("A", "item-1", item("item-1", "A", 1), None) .await?; - for (id, pk) in [("missing", "A"), ("item-1", "B")] { - let error = fixture - .container - .read_item(pk, id, None) - .await - .expect_err("read must return not found"); - assert_eq!(error.status().status_code(), StatusCode::NotFound); - assert_eq!( - error - .status() - .sub_status() - .map(|value| value.value()) - .unwrap_or(0), - 0 - ); - assert_critical_diagnostics( - &error - .diagnostics() - .expect("service error must carry diagnostics"), - "read_item", - StatusCode::NotFound, - ); - } + + // Case 1: a missing ID in the correct partition returns a plain 404. + let missing = fixture + .container + .read_item("A", "missing", None) + .await + .expect_err("missing item read must fail"); + assert_plain_not_found(&missing); + + // Case 2: the existing ID cannot be read through a different partition key. + let wrong_partition = fixture + .container + .read_item("B", "item-1", None) + .await + .expect_err("wrong-partition-key read must fail"); + assert_plain_not_found(&wrong_partition); + + // Neither failed read mutated or hid the correctly addressed item. let read = fixture.container.read_item("A", "item-1", None).await?; - assert_eq!(read.into_model::()?.value, 1); + assert_eq!(read.into_model::()?, item("item-1", "A", 1)); Ok(()) }) .await } + +fn assert_plain_not_found(error: &azure_data_cosmos::CosmosError) { + assert_eq!(error.status().status_code(), StatusCode::NotFound); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + assert_critical_diagnostics( + &error + .diagnostics() + .expect("service error must carry diagnostics"), + "read_item", + StatusCode::NotFound, + ); +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs index aba42fe306b..75b5d57103e 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs @@ -19,6 +19,7 @@ async fn stale_etag_preserves_successful_update() -> TestResult { return Ok(()); } E2eTestFixture::run(async |fixture| { + // Arrange an item and retain its initial ETag. let created = fixture .container .create_item("A", "etag-1", item("etag-1", "A", 1), None) @@ -28,6 +29,8 @@ async fn stale_etag_preserves_successful_update() -> TestResult { .etag() .expect("create must return an ETag") .clone(); + + // Case 1: the current ETag permits the update from value 1 to value 2. let current_options = ItemWriteOptions::default() .with_precondition(Precondition::IfMatch(initial_etag.clone())); let replaced = fixture @@ -35,6 +38,8 @@ async fn stale_etag_preserves_successful_update() -> TestResult { .replace_item("A", "etag-1", item("etag-1", "A", 2), Some(current_options)) .await?; assert_eq!(replaced.status().status_code(), StatusCode::Ok); + + // Case 2: reusing the now-stale initial ETag cannot overwrite value 2. let stale_options = ItemWriteOptions::default() .with_precondition(Precondition::IfMatch(Etag::from(initial_etag.to_string()))); let error = fixture @@ -58,14 +63,15 @@ async fn stale_etag_preserves_successful_update() -> TestResult { "replace_item", StatusCode::PreconditionFailed, ); + + // The rejected write leaves the successful value 2 update intact. assert_eq!( fixture .container .read_item("A", "etag-1", None) .await? - .into_model::()? - .value, - 2 + .into_model::()?, + item("etag-1", "A", 2) ); Ok(()) }) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs index f17c12bb90c..9cea677333d 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs @@ -20,6 +20,7 @@ async fn upsert_creates_then_updates() -> TestResult { return Ok(()); } E2eTestFixture::run(async |fixture| { + // Case 1: upserting a missing identity creates value 1 and returns 201. let created = fixture .container .upsert_item( @@ -31,6 +32,8 @@ async fn upsert_creates_then_updates() -> TestResult { .await?; assert_eq!(created.status().status_code(), StatusCode::Created); assert_critical_diagnostics(&created.diagnostics(), "upsert_item", StatusCode::Created); + + // Case 2: upserting the same identity replaces it with value 2 and returns 200. let updated = fixture .container .upsert_item( @@ -42,7 +45,9 @@ async fn upsert_creates_then_updates() -> TestResult { .await?; assert_eq!(updated.status().status_code(), StatusCode::Ok); assert_critical_diagnostics(&updated.diagnostics(), "upsert_item", StatusCode::Ok); - assert_eq!(updated.into_model::()?.value, 2); + assert_eq!(updated.into_model::()?, item("upsert-1", "A", 2)); + + // Both operations addressed one identity; no duplicate document was created. let items: Vec = fixture .container .query_items( diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs index 0d423163e19..9c2af0ece4e 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs @@ -20,6 +20,7 @@ async fn invalid_query_is_not_an_empty_feed() -> TestResult { return Ok(()); } E2eTestFixture::run(async |fixture| { + // Query failures may surface while creating the iterator or while reading its first page. let result = fixture .container .query_items::("SELECT FROM", FeedScope::partition("A"), None) @@ -32,6 +33,8 @@ async fn invalid_query_is_not_an_empty_feed() -> TestResult { .expect("invalid query must produce an error") .expect_err("invalid query must not produce a page"), }; + + // Invalid syntax is a typed bad request, never an empty successful feed. assert_eq!(error.status().status_code(), StatusCode::BadRequest); Ok(()) }) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs index 865ecf18212..17c62ae40f8 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs @@ -19,12 +19,15 @@ async fn parameterized_query_filters_and_orders() -> TestResult { return Ok(()); } E2eTestFixture::run(async |fixture| { + // Arrange three ordered scores in one logical partition. for score in 1..=3 { let id = format!("item-{score}"); let mut value = item(&id, "A", score); value.score = Some(score); fixture.container.create_item("A", &id, value, None).await?; } + + // Bind values as parameters and restrict execution to partition A. let query = Query::from( "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score ASC", ) @@ -35,6 +38,8 @@ async fn parameterized_query_filters_and_orders() -> TestResult { .query_items::(query, FeedScope::partition("A"), None) .await?; let items: Vec = results.by_ref().try_collect().await?; + + // The filter excludes score 1 and ORDER BY preserves score 2 before score 3. assert_eq!( items .iter() From 958271312da4199a7ac5502e5eb6dff4739ad001 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Fri, 11 Sep 2026 21:17:31 +0000 Subject: [PATCH 05/19] Reacting to code review comments --- .../tests/e2e_test_cases/bootstrap_primary.rs | 36 +- .../tests/e2e_test_cases/capabilities.rs | 6 +- .../tests/e2e_test_cases/catalog.rs | 72 ++- .../diagnostics_success_and_error.rs | 2 +- .../tests/e2e_test_cases/fixture.rs | 151 ++++-- .../e2e_test_cases/item_create_conflict.rs | 35 +- .../tests/e2e_test_cases/item_lifecycle.rs | 429 +++++++++++------- .../tests/e2e_test_cases/item_not_found.rs | 2 +- .../item_optimistic_concurrency.rs | 10 +- .../tests/e2e_test_cases/item_upsert.rs | 2 +- .../e2e_test_cases/query_invalid_syntax.rs | 2 +- .../query_parameterized_filter.rs | 18 +- .../tests/e2e_test_cases/support.rs | 117 ++++- .../src/driver/pipeline/operation_pipeline.rs | 88 +++- .../src/options/read_consistency.rs | 5 +- sdk/cosmos/docs/specs/0011-gateway-v2.md | 6 +- sdk/cosmos/e2e-consistency-matrix.json | 2 + sdk/cosmos/e2e-profile-matrix.json | 33 -- .../e2e-read-consistency-override-matrix.json | 3 +- .../e2e_tests/profiles/legacyGatewayV1.json | 36 -- .../e2e_tests/profiles/targetDefault.json | 39 -- .../eng/scripts/Invoke-CosmosTestSetup.ps1 | 48 +- 22 files changed, 759 insertions(+), 383 deletions(-) delete mode 100644 sdk/cosmos/e2e-profile-matrix.json delete mode 100644 sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json delete mode 100644 sdk/cosmos/e2e_tests/profiles/targetDefault.json diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs index 1a018978b8a..b3c07fae0c7 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs @@ -1,11 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -use azure_core::http::StatusCode; - use crate::e2e_test_cases::{ - fixture::{build_client, TestResult}, - support::{hosted_only, should_run}, + fixture::{E2eTestFixture, TestResult}, + support::{item, should_run, Item}, }; #[tokio::test] @@ -14,21 +12,23 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn bootstrap_primary_endpoint() -> TestResult { - if !should_run("bootstrap.primary-success")? { + if !should_run("bootstrap.primary-success").await? { return Ok(()); } - // Building the public SDK client against the reachable primary endpoint succeeds. - let client = build_client().await?; - assert!(hosted_only()); - - // The initialized client can complete its first account operation. - let database_id = format!("e2e-bootstrap-{}", azure_core::Uuid::new_v4()); - let response = client.create_database(&database_id, None).await?; - assert_eq!(response.status().status_code(), StatusCode::Created); - response.into_model()?; - - // Remove the resource created only to prove successful bootstrap. - client.database_client(&database_id).delete(None).await?; - Ok(()) + E2eTestFixture::run(async |fixture| { + let expected = item("bootstrap-1", "A", 1); + fixture + .container + .create_item("A", &expected.id, &expected, None) + .await?; + let actual = fixture + .container + .read_item("A", &expected.id, None) + .await? + .into_model::()?; + assert_eq!(actual, expected); + Ok(()) + }) + .await } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs index 4470bcd2fc4..e0783528374 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs @@ -11,13 +11,15 @@ use crate::e2e_test_cases::{fixture::TestResult, support::should_run}; ignore = "requires the externally hosted in-memory emulator" )] async fn capability_document_is_versioned() -> TestResult { - if !should_run("management.capabilities")? { + if !should_run("management.capabilities").await? { return Ok(()); } // Read the emulator's public management document without using Rust internals. let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; - let response = reqwest::Client::new() + let response = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()? .get(url::Url::parse(&management_endpoint)?.join("capabilities")?) .send() .await?; diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs index 69910d782b6..c72c6c56230 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs @@ -32,8 +32,6 @@ const SCENARIOS: &[&str] = &[ const PROFILES: &[&str] = &[ include_str!("../../../e2e_tests/profiles/hostedEmulatorSmoke.json"), - include_str!("../../../e2e_tests/profiles/targetDefault.json"), - include_str!("../../../e2e_tests/profiles/legacyGatewayV1.json"), include_str!("../../../e2e_tests/profiles/lifecycleConsistencyMatrix.json"), include_str!("../../../e2e_tests/profiles/readConsistencyOverrideMatrix.json"), ]; @@ -96,9 +94,9 @@ struct Backend { requires: Vec, } -#[derive(Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "camelCase")] -enum Capability { +pub(super) enum Capability { Capabilities, GatewayV2, } @@ -221,6 +219,49 @@ impl Profile { .find(|definition| definition.id == id) .unwrap_or_else(|| panic!("profile '{}' has no client '{id}'", self.id)) } + + pub fn selected_account(&self) -> Result<&AccountDefinition, String> { + let ids: Vec<_> = self + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + Ok(self.account(selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &ids)?)) + } + + pub fn selected_runtime(&self) -> Result<&RuntimeDefinition, String> { + let ids: Vec<_> = self + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + Ok(self.runtime(selected_axis("AZURE_COSMOS_E2E_RUNTIME", &ids)?)) + } + + pub fn selected_client(&self) -> Result<&ClientDefinition, String> { + let ids: Vec<_> = self + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + Ok(self.client(selected_axis("AZURE_COSMOS_E2E_CLIENT", &ids)?)) + } +} + +fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> Result<&'a str, String> { + match std::env::var(environment_variable) { + Ok(selected) => available + .iter() + .copied() + .find(|candidate| *candidate == selected) + .ok_or_else(|| { + format!("{environment_variable}='{selected}' is not one of {available:?}") + }), + Err(_) if available.len() == 1 => Ok(available[0]), + Err(_) => Err(format!( + "{environment_variable} is required because this profile defines {available:?}" + )), + } } fn load_scenarios() -> Result, String> { @@ -255,6 +296,23 @@ pub fn selected_profile_for(scenario_id: &str) -> Result, String Ok(profiles.into_iter().find(|profile| profile.id == selected)) } +pub(super) fn required_capabilities_for( + scenario_id: &str, + backend: &str, +) -> Result, String> { + let scenarios = load_scenarios()?; + let scenario = scenarios + .iter() + .find(|scenario| scenario.id == scenario_id) + .ok_or_else(|| format!("E2E scenario '{scenario_id}' does not exist"))?; + Ok(scenario + .backends + .get(backend) + .ok_or_else(|| format!("E2E scenario '{scenario_id}' has no backend '{backend}'"))? + .requires + .clone()) +} + pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { for (name, schema) in [("scenario", SCENARIO_SCHEMA), ("profile", PROFILE_SCHEMA)] { let schema: Value = serde_json::from_str(schema) @@ -442,6 +500,12 @@ pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { "selected E2E profile '{selected_profile}' has no active scenarios" )); } + let selected_scenarios: Vec<_> = scenarios + .iter() + .filter(|scenario| scenario.profiles.contains(&selected_profile)) + .map(|scenario| scenario.id.as_str()) + .collect(); + eprintln!("E2E profile '{selected_profile}' selects scenarios: {selected_scenarios:?}"); } let implementations: ImplementationMap = diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs index 6b5d99ae2f6..ec1e0c00dec 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs @@ -15,7 +15,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn diagnostics_cover_success_and_error() -> TestResult { - if !should_run("diagnostics.success-and-error")? { + if !should_run("diagnostics.success-and-error").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs index 8b9b4006d7f..1b2becbc993 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +use std::panic::AssertUnwindSafe; + use azure_core::Uuid; use azure_data_cosmos::{ clients::ContainerClient, @@ -11,13 +13,67 @@ use azure_data_cosmos::{ }, AccountEndpoint, AccountReference, CosmosClient, CosmosRuntime, RoutingStrategy, }; +use futures::FutureExt; + +use crate::e2e_test_cases::catalog::{ClientDefinition, RuntimeDefinition}; pub type TestResult = Result>; pub struct E2eTestFixture { + cleanup: DatabaseCleanup, + pub container: ContainerClient, +} + +pub struct ClientSetup { + pub routing_strategy: RoutingStrategy, + pub runtime_read_consistency: Option, + pub client_read_consistency: Option, + pub gateway_v2_enabled: Option, + pub ppcb_enabled: Option, + pub binary_encoding_enabled: Option, +} + +impl ClientSetup { + pub fn from_profile( + runtime: &RuntimeDefinition, + client: &ClientDefinition, + routing_strategy: RoutingStrategy, + ) -> TestResult { + Ok(Self { + routing_strategy, + runtime_read_consistency: parse_optional_read_consistency( + runtime.default_read_consistency_strategy.as_deref(), + )?, + client_read_consistency: parse_optional_read_consistency( + client.default_read_consistency_strategy.as_deref(), + )?, + gateway_v2_enabled: parse_setup_switch(&runtime.gateway_v2, "backendDefault")?, + ppcb_enabled: parse_setup_switch(&runtime.ppcb, "sdkDefault")?, + binary_encoding_enabled: parse_setup_switch(&client.binary_encoding, "sdkDefault")?, + }) + } +} + +fn parse_optional_read_consistency( + value: Option<&str>, +) -> TestResult> { + value + .map(|value| value.parse::().map_err(Into::into)) + .transpose() +} + +fn parse_setup_switch(value: &str, default: &str) -> TestResult> { + match value { + "enabled" => Ok(Some(true)), + "disabled" => Ok(Some(false)), + value if value == default => Ok(None), + value => Err(format!("unsupported setup switch '{value}'").into()), + } +} + +struct DatabaseCleanup { client: CosmosClient, database_id: String, - pub container: ContainerClient, } impl E2eTestFixture { @@ -48,10 +104,24 @@ impl E2eTestFixture { F: AsyncFnOnce(&E2eTestFixture) -> TestResult, { let fixture = Self::new(client, partition_key).await?; - let outcome = test(&fixture).await; + let outcome = AssertUnwindSafe(test(&fixture)).catch_unwind().await; let cleanup = fixture.cleanup().await; - outcome?; - cleanup + match outcome { + Ok(Ok(())) => cleanup, + Ok(Err(test_error)) => match cleanup { + Ok(()) => Err(test_error), + Err(cleanup_error) => Err(format!( + "E2E test failed: {test_error}; database cleanup also failed: {cleanup_error}" + ) + .into()), + }, + Err(panic) => { + if let Err(error) = cleanup { + eprintln!("E2E database cleanup after panic failed: {error}"); + } + std::panic::resume_unwind(panic) + } + } } async fn new(client: CosmosClient, partition_key: PartitionKeyDefinition) -> TestResult { @@ -59,18 +129,40 @@ impl E2eTestFixture { let container_id = format!("items-{}", Uuid::new_v4()); client.create_database(&database_id, None).await?; let database = client.database_client(&database_id); - database - .create_container( - ContainerProperties::new(container_id.clone(), partition_key), - None, - ) - .await?; - let container = database.container_client(&container_id, None).await?; - Ok(Self { + let cleanup = DatabaseCleanup::new(client.clone(), database_id); + let setup = async { + database + .create_container( + ContainerProperties::new(container_id.clone(), partition_key), + None, + ) + .await?; + database.container_client(&container_id, None).await + } + .await; + match setup { + Ok(container) => Ok(Self { cleanup, container }), + Err(setup_error) => match cleanup.cleanup().await { + Ok(()) => Err(setup_error.into()), + Err(cleanup_error) => Err(format!( + "E2E fixture setup failed: {setup_error}; database cleanup also failed: {cleanup_error}" + ) + .into()), + }, + } + } + + async fn cleanup(self) -> TestResult { + self.cleanup.cleanup().await + } +} + +impl DatabaseCleanup { + fn new(client: CosmosClient, database_id: String) -> Self { + Self { client, database_id, - container, - }) + } } async fn cleanup(self) -> TestResult { @@ -89,29 +181,30 @@ pub async fn build_client() -> TestResult { pub async fn build_client_with_routing( routing_strategy: RoutingStrategy, ) -> TestResult { - build_client_with_defaults(routing_strategy, None, None, None, None, None).await + build_client_with_defaults(ClientSetup { + routing_strategy, + runtime_read_consistency: None, + client_read_consistency: None, + gateway_v2_enabled: None, + ppcb_enabled: None, + binary_encoding_enabled: None, + }) + .await } -pub async fn build_client_with_defaults( - routing_strategy: RoutingStrategy, - runtime_strategy: Option, - client_strategy: Option, - gateway_v2_enabled: Option, - ppcb_enabled: Option, - binary_encoding_enabled: Option, -) -> TestResult { +pub async fn build_client_with_defaults(setup: ClientSetup) -> TestResult { let connection_string = std::env::var("AZURE_COSMOS_CONNECTION_STRING")?; let endpoint = connection_string_value(&connection_string, "AccountEndpoint")?; let key = connection_string_value(&connection_string, "AccountKey")?; let endpoint: AccountEndpoint = endpoint.parse()?; let mut runtime_builder = CosmosRuntime::builder(); - if let Some(enabled) = gateway_v2_enabled { + if let Some(enabled) = setup.gateway_v2_enabled { let options = ConnectionPoolOptions::builder() .with_gateway_v2_disabled(!enabled) .build()?; runtime_builder = runtime_builder.with_connection_pool(options); } - if let Some(strategy) = runtime_strategy { + if let Some(strategy) = setup.runtime_read_consistency { let mut options = OperationOptions::default(); options.read_consistency_strategy = Some(strategy); runtime_builder = runtime_builder.with_default_operation_options(options); @@ -119,25 +212,25 @@ pub async fn build_client_with_defaults( let runtime = runtime_builder.build().await?; let mut client_builder = CosmosClient::builder().with_runtime(runtime); - if let Some(strategy) = client_strategy { + if let Some(strategy) = setup.client_read_consistency { let mut options = OperationOptions::default(); options.read_consistency_strategy = Some(strategy); client_builder = client_builder.with_default_operation_options(options); } - if let Some(enabled) = ppcb_enabled { + if let Some(enabled) = setup.ppcb_enabled { let options = PartitionFailoverOptions::builder() .with_circuit_breaker_enabled(enabled) .build()?; client_builder = client_builder.with_partition_failover_options(options); } - if let Some(enabled) = binary_encoding_enabled { + if let Some(enabled) = setup.binary_encoding_enabled { client_builder = client_builder .with_binary_encoding_options(BinaryEncodingOptions::new().with_enabled(enabled)); } Ok(client_builder .build( AccountReference::with_authentication_key(endpoint, key), - routing_strategy, + setup.routing_strategy, ) .await?) } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs index ba3ad9210e8..f554aaa74bb 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs @@ -20,7 +20,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn duplicate_create_preserves_original() -> TestResult { - if !should_run("item.create-conflict")? { + if !should_run("item.create-conflict").await? { return Ok(()); } for case in duplicate_create_cases() { @@ -74,24 +74,26 @@ async fn duplicate_create_preserves_original() -> TestResult { StatusCode::Conflict, ); - // The failed duplicate create leaves every original field unchanged. - let stored: Value = fixture + // The failed duplicate create leaves the complete user document unchanged. + let mut stored: Value = fixture .container .read_item(case.partition_key.clone(), document_id, None) .await? .into_model()?; - for (name, expected) in case - .original - .as_object() - .expect("original document must be an object") - { - assert_eq!( - stored.get(name), - Some(expected), - "fixture '{}' field '{name}' changed after duplicate create", - case.id - ); + let stored = stored + .as_object_mut() + .expect("stored document must be an object"); + for system_property in ["_rid", "_self", "_etag", "_attachments", "_ts"] { + stored.remove(system_property); } + assert_eq!( + stored, + case.original + .as_object() + .expect("original document must be an object"), + "fixture '{}' changed after duplicate create", + case.id + ); Ok(()) }) .await?; @@ -117,7 +119,7 @@ fn duplicate_create_cases() -> Vec { .with_version(version), partition_key: PartitionKey::from("A"), original: json!({ "id": "duplicate-1", "pk": "A", "value": 1 }), - duplicate: json!({ "id": "duplicate-1", "pk": "A", "value": 2 }), + duplicate: json!({ "id": "duplicate-1", "pk": "A", "value": 2, "injected": true }), }; vec![ @@ -145,7 +147,8 @@ fn duplicate_create_cases() -> Vec { "id": "duplicate-1", "tenant": "tenant-a", "user": "user-1", - "value": 2 + "value": 2, + "injected": true }), }, ] diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index cccea04d3c5..c77c9a7b102 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -11,13 +11,15 @@ use azure_data_cosmos::{ }, RoutingStrategy, }; +use futures::FutureExt; use crate::e2e_test_cases::{ - catalog::{ - selected_profile_for, AccountDefinition, ClientDefinition, Profile, RuntimeDefinition, + catalog::{AccountDefinition, ClientDefinition, Profile, RuntimeDefinition}, + fixture::{build_client_with_defaults, ClientSetup, E2eTestFixture, TestResult}, + support::{ + assert_critical_diagnostics, item, selected_scenario_profile, write_options_with_content, + Item, }, - fixture::{build_client_with_defaults, E2eTestFixture, TestResult}, - support::{assert_critical_diagnostics, item, write_options_with_content, Item}, }; const REPLICATION_TIMEOUT: Duration = Duration::from_secs(5); @@ -35,7 +37,7 @@ const RETRY_DELAY: Duration = Duration::from_millis(50); ignore = "requires the externally hosted in-memory emulator" )] async fn item_lifecycle() -> TestResult { - let Some(profile) = selected_profile_for("item.lifecycle")? else { + let Some(profile) = selected_scenario_profile("item.lifecycle").await? else { return Ok(()); }; let setup = SelectedLifecycleSetup::from_profile(&profile)?; @@ -55,78 +57,127 @@ async fn run_lifecycle_case( ) -> TestResult { let execution = setup.execution_name(read_case.name); let client = setup.build_client().await?; + let deterministic_delay = matches!( + read_case.expectation, + ReadExpectation::EventuallySucceeds { .. } + ); + if deterministic_delay { + set_replication_paused(true).await?; + } - E2eTestFixture::run_with_client(client, "/pk".into(), async |fixture| { - let item_id = format!("lifecycle-{}", execution.replace('/', "-")); - - // Create an item and capture the session token used by explicit Session reads. - let created = fixture - .container - .create_item("A", &item_id, item(&item_id, "A", 1), None) + let outcome = std::panic::AssertUnwindSafe(E2eTestFixture::run_with_client( + client, + "/pk".into(), + async |fixture| { + let item_id = format!("lifecycle-{}", execution.replace('/', "-")); + + // Create an item and capture the session token used by explicit Session reads. + let created = fixture + .container + .create_item("A", &item_id, item(&item_id, "A", 1), None) + .await?; + assert_eq!(created.status().status_code(), StatusCode::Created); + assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); + let create_session_token = created + .headers() + .session_token() + .map(|token| token.as_str().to_owned()); + + // Read the created item using this case's operation-level consistency behavior. + let read_outcome = read_created_item( + &fixture.container, + &item_id, + create_session_token, + read_case, + &execution, + ) .await?; - assert_eq!(created.status().status_code(), StatusCode::Created); - assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); - let create_session_token = created - .headers() - .session_token() - .map(|token| token.as_str().to_owned()); - - // Read the created item using this case's operation-level consistency behavior. - let read_outcome = read_created_item( - &fixture.container, - &item_id, - create_session_token, - read_case, - &execution, - ) - .await?; - match read_outcome { - PostCreateReadOutcome::Item(actual) => assert_eq!( - actual, - item(&item_id, "A", 1), - "read returned the wrong item for '{execution}'" - ), - PostCreateReadOutcome::RejectedBeforeTransport => assert_eq!( - read_case.expectation, - ReadExpectation::RejectedBeforeTransport, - "read was rejected unexpectedly for '{execution}'" - ), - } + match read_outcome { + PostCreateReadOutcome::Item(actual) => assert_eq!( + actual, + item(&item_id, "A", 1), + "read returned the wrong item for '{execution}'" + ), + PostCreateReadOutcome::RejectedBeforeTransport => assert_eq!( + read_case.expectation, + ReadExpectation::RejectedBeforeTransport, + "read was rejected unexpectedly for '{execution}'" + ), + } - // Replace the item and verify the returned model. - let replaced = fixture - .container - .replace_item( - "A", + // Replace the item and verify the returned model. + let replaced = fixture + .container + .replace_item( + "A", + &item_id, + item(&item_id, "A", 2), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); + assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); + + // Delete the item, then use the delete session token to verify a plain 404/0. + let deleted = fixture.container.delete_item("A", &item_id, None).await?; + assert_eq!(deleted.status().status_code(), StatusCode::NoContent); + assert_critical_diagnostics( + &deleted.diagnostics(), + "delete_item", + StatusCode::NoContent, + ); + let delete_session_token = deleted + .headers() + .session_token() + .map(|token| token.as_str().to_owned()) + .ok_or("delete response must carry a session token")?; + assert_item_deleted( + &fixture.container, &item_id, - item(&item_id, "A", 2), - Some(write_options_with_content()), + delete_session_token, + &execution, ) .await?; - assert_eq!(replaced.status().status_code(), StatusCode::Ok); - assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); - assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); - - // Delete the item, then use the delete session token to verify a plain 404/0. - let deleted = fixture.container.delete_item("A", &item_id, None).await?; - assert_eq!(deleted.status().status_code(), StatusCode::NoContent); - assert_critical_diagnostics(&deleted.diagnostics(), "delete_item", StatusCode::NoContent); - let delete_session_token = deleted - .headers() - .session_token() - .map(|token| token.as_str().to_owned()) - .ok_or("delete response must carry a session token")?; - assert_item_deleted( - &fixture.container, - &item_id, - delete_session_token, - &execution, - ) - .await?; + Ok(()) + }, + )) + .catch_unwind() + .await; + let resume = if deterministic_delay { + set_replication_paused(false).await + } else { Ok(()) - }) - .await + }; + match outcome { + Ok(result) => { + result?; + resume + } + Err(panic) => { + if let Err(error) = resume { + eprintln!("resuming E2E replication after panic failed: {error}"); + } + std::panic::resume_unwind(panic) + } + } +} + +async fn set_replication_paused(paused: bool) -> TestResult { + let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; + let action = if paused { "pause" } else { "resume" }; + reqwest::Client::builder() + .timeout(REPLICATION_TIMEOUT) + .build()? + .post( + url::Url::parse(&management_endpoint)? + .join(&format!("regions/West%20US/replication/{action}"))?, + ) + .send() + .await? + .error_for_status()?; + Ok(()) } // Operation cases ------------------------------------------------------------- @@ -289,9 +340,14 @@ fn read_cases_for_default_precedence( } // Effective precedence is operation > client > runtime > account. These cases vary only the // operation value; the JSON profile supplies the selected client and runtime defaults. - let inherited_uses_session = match client_default.or(runtime_default) { - Some(strategy) => parse_read_consistency(strategy)? == ReadConsistencyStrategy::Session, - None => account.consistency == "session", + let inherited_strategy = match client_default { + Some(strategy) => Some(parse_read_consistency(strategy)?), + None => runtime_default.map(parse_read_consistency).transpose()?, + }; + let inherited_uses_session = match inherited_strategy { + Some(ReadConsistencyStrategy::Default) | None => account.consistency == "session", + Some(ReadConsistencyStrategy::Session) => true, + Some(_) => false, }; let inherited_expectation = if inherited_uses_session { eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) @@ -314,7 +370,7 @@ fn read_cases_for_default_precedence( PostCreateReadCase::new( "default_override_restores_account_consistency", Some(ReadConsistencyStrategy::Default), - SessionTokenBehavior::ExplicitCreateResponse, + SessionTokenBehavior::SdkManaged, eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), ), PostCreateReadCase::new( @@ -347,25 +403,9 @@ struct SelectedLifecycleSetup<'a> { impl<'a> SelectedLifecycleSetup<'a> { fn from_profile(profile: &'a Profile) -> TestResult { - let account_ids: Vec<_> = profile - .accounts - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let runtime_ids: Vec<_> = profile - .runtimes - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let client_ids: Vec<_> = profile - .clients - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - - let account = profile.account(selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &account_ids)?); - let runtime = profile.runtime(selected_axis("AZURE_COSMOS_E2E_RUNTIME", &runtime_ids)?); - let client = profile.client(selected_axis("AZURE_COSMOS_E2E_CLIENT", &client_ids)?); + let account = profile.selected_account()?; + let runtime = profile.selected_runtime()?; + let client = profile.selected_client()?; let read_region = lifecycle_read_region(profile)?; let routing = lifecycle_routing(client, &read_region)?; @@ -379,18 +419,11 @@ impl<'a> SelectedLifecycleSetup<'a> { } async fn build_client(&self) -> TestResult { - build_client_with_defaults( + build_client_with_defaults(ClientSetup::from_profile( + self.runtime, + self.client, self.routing.clone(), - parse_optional_read_consistency( - self.runtime.default_read_consistency_strategy.as_deref(), - )?, - parse_optional_read_consistency( - self.client.default_read_consistency_strategy.as_deref(), - )?, - parse_setup_switch(&self.runtime.gateway_v2, "backendDefault")?, - parse_setup_switch(&self.runtime.ppcb, "sdkDefault")?, - parse_setup_switch(&self.client.binary_encoding, "sdkDefault")?, - ) + )?) .await } @@ -484,13 +517,17 @@ async fn read_created_item( status_code: response.status().status_code(), substatus: response.status().sub_status().map(|value| value.value()), }; - record_request_statuses(&response.diagnostics(), &mut observed_statuses); - observed_statuses.push(status); + record_request_statuses_or_outer( + &response.diagnostics(), + status, + &mut observed_statuses, + ); if read_case .expectation .terminal_status() .matches(status.status_code, status.substatus) { + validate_observed_statuses(read_case, &observed_statuses, execution)?; assert_critical_diagnostics( &response.diagnostics(), "read_item", @@ -512,14 +549,16 @@ async fn read_created_item( substatus: error.status().sub_status().map(|value| value.value()), }; if let Some(diagnostics) = error.diagnostics() { - record_request_statuses(&diagnostics, &mut observed_statuses); + record_request_statuses_or_outer(&diagnostics, status, &mut observed_statuses); + } else { + observed_statuses.push(status); } - observed_statuses.push(status); if read_case .expectation .terminal_status() .matches(status.status_code, status.substatus) { + validate_observed_statuses(read_case, &observed_statuses, execution)?; if read_case.expectation == ReadExpectation::RejectedBeforeTransport { assert!( error.response().is_none(), @@ -543,12 +582,68 @@ async fn read_created_item( execution, &observed_statuses, )?; + if matches!( + read_case.expectation, + ReadExpectation::EventuallySucceeds { .. } + ) { + set_replication_paused(false).await?; + } } } tokio::time::sleep(RETRY_DELAY).await; } } +fn record_request_statuses_or_outer( + diagnostics: &azure_data_cosmos::diagnostics::DiagnosticsContext, + outer_status: ActualHttpStatus, + statuses: &mut Vec, +) { + let previous_len = statuses.len(); + record_request_statuses(diagnostics, statuses); + if statuses.len() == previous_len { + statuses.push(outer_status); + } +} + +fn validate_observed_statuses( + read_case: &PostCreateReadCase, + observed_statuses: &[ActualHttpStatus], + execution: &str, +) -> TestResult { + let terminal = read_case.expectation.terminal_status(); + let allowed = read_case.expectation.allowed_transient_statuses(); + if observed_statuses.iter().any(|actual| { + !terminal.matches(actual.status_code, actual.substatus) + && !allowed.iter().any(|expected| { + expected + .http_status() + .matches(actual.status_code, actual.substatus) + }) + }) { + return Err(format!( + "'{execution}' observed statuses outside its contract: {observed_statuses:?}" + ) + .into()); + } + if matches!( + read_case.expectation, + ReadExpectation::EventuallySucceeds { .. } + ) && !observed_statuses.iter().any(|actual| { + allowed.iter().any(|expected| { + expected + .http_status() + .matches(actual.status_code, actual.substatus) + }) + }) { + return Err(format!( + "'{execution}' did not observe the expected delayed-read transient; observed {observed_statuses:?}" + ) + .into()); + } + Ok(()) +} + async fn assert_item_deleted( container: &ContainerClient, item_id: &str, @@ -581,12 +676,15 @@ async fn assert_item_deleted( error.status().status_code(), error.status().sub_status().map(|value| value.value()), ) && tokio::time::Instant::now() < deadline => {} - Ok(_) if tokio::time::Instant::now() < deadline => {} - Ok(_) => { + Ok(response) => { + let diagnostics = response.diagnostics(); + let actual = response.into_model::()?; return Err(format!( - "deleted item for '{execution}' remained visible after {REPLICATION_TIMEOUT:?}" + "deleted item for '{execution}' remained visible despite the delete session token: {actual:?}; regions: {:?}; requests: {:?}", + diagnostics.regions_contacted(), + diagnostics.requests() ) - .into()) + .into()); } Err(error) => return Err(error.into()), } @@ -694,42 +792,10 @@ fn lifecycle_routing( } } -fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> TestResult<&'a str> { - match std::env::var(environment_variable) { - Ok(selected) => available - .iter() - .copied() - .find(|candidate| *candidate == selected) - .ok_or_else(|| { - format!("{environment_variable}='{selected}' is not one of {available:?}").into() - }), - Err(_) if available.len() == 1 => Ok(available[0]), - Err(_) => Err(format!( - "{environment_variable} is required because this profile defines {available:?}" - ) - .into()), - } -} - -fn parse_optional_read_consistency( - value: Option<&str>, -) -> TestResult> { - value.map(parse_read_consistency).transpose() -} - fn parse_read_consistency(value: &str) -> TestResult { value.parse::().map_err(Into::into) } -fn parse_setup_switch(value: &str, default: &str) -> TestResult> { - match value { - "enabled" => Ok(Some(true)), - "disabled" => Ok(Some(false)), - value if value == default => Ok(None), - value => Err(format!("unsupported setup switch '{value}'").into()), - } -} - #[cfg(test)] mod tests { use super::*; @@ -852,24 +918,53 @@ mod tests { )) .expect("override profile must deserialize"); let account = profile.account("session"); + let inherited_expectations = [ + ( + "unset", + "unset", + SessionTokenBehavior::SdkManaged, + TransientReadStatus::SessionNotAvailable, + ), + ( + "eventual", + "unset", + SessionTokenBehavior::Omitted, + TransientReadStatus::PlainNotFound, + ), + ( + "session", + "unset", + SessionTokenBehavior::SdkManaged, + TransientReadStatus::SessionNotAvailable, + ), + ( + "unset", + "latestCommitted", + SessionTokenBehavior::Omitted, + TransientReadStatus::PlainNotFound, + ), + ( + "eventual", + "latestCommitted", + SessionTokenBehavior::Omitted, + TransientReadStatus::PlainNotFound, + ), + ( + "session", + "latestCommitted", + SessionTokenBehavior::Omitted, + TransientReadStatus::PlainNotFound, + ), + ]; for runtime in &profile.runtimes { for client in &profile.clients { - let inherited_uses_session = client - .default_read_consistency_strategy - .as_deref() - .or(runtime.default_read_consistency_strategy.as_deref()) - .is_none_or(|strategy| strategy == "Session"); - let inherited_expectation = if inherited_uses_session { - eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) - } else { - eventually_succeeds_after(TransientReadStatus::PlainNotFound) - }; - let inherited_token = if inherited_uses_session { - SessionTokenBehavior::SdkManaged - } else { - SessionTokenBehavior::Omitted - }; + let (_, _, inherited_token, inherited_transient) = inherited_expectations + .iter() + .find(|(runtime_id, client_id, _, _)| { + *runtime_id == runtime.id && *client_id == client.id + }) + .expect("every override profile cell must have a hard-coded expectation"); let actual = read_cases_for_default_precedence( account, @@ -881,13 +976,13 @@ mod tests { PostCreateReadCase::new( "inherits_client_then_runtime_then_account_default", None, - inherited_token, - inherited_expectation, + *inherited_token, + eventually_succeeds_after(*inherited_transient), ), PostCreateReadCase::new( "default_override_restores_account_consistency", Some(ReadConsistencyStrategy::Default), - SessionTokenBehavior::ExplicitCreateResponse, + SessionTokenBehavior::SdkManaged, eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), ), PostCreateReadCase::new( @@ -904,6 +999,18 @@ mod tests { ); } } + + let client_default = + read_cases_for_default_precedence(account, Some("Eventual"), Some("Default")) + .expect("client Default must be valid"); + assert_eq!( + client_default[0].session_token, + SessionTokenBehavior::SdkManaged + ); + assert_eq!( + client_default[0].expectation, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) + ); } #[test] diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs index 2e6e9f0599b..68b62f1fc2a 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs @@ -14,7 +14,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn not_found_does_not_cross_partition_keys() -> TestResult { - if !should_run("item.not-found-wrong-partition-key")? { + if !should_run("item.not-found-wrong-partition-key").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs index 75b5d57103e..e44192bd485 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -use azure_core::http::{Etag, StatusCode}; +use azure_core::http::StatusCode; use azure_data_cosmos::options::{ItemWriteOptions, Precondition}; use crate::e2e_test_cases::{ @@ -15,7 +15,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn stale_etag_preserves_successful_update() -> TestResult { - if !should_run("item.optimistic-concurrency")? { + if !should_run("item.optimistic-concurrency").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { @@ -24,6 +24,7 @@ async fn stale_etag_preserves_successful_update() -> TestResult { .container .create_item("A", "etag-1", item("etag-1", "A", 1), None) .await?; + assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); let initial_etag = created .headers() .etag() @@ -38,10 +39,11 @@ async fn stale_etag_preserves_successful_update() -> TestResult { .replace_item("A", "etag-1", item("etag-1", "A", 2), Some(current_options)) .await?; assert_eq!(replaced.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); // Case 2: reusing the now-stale initial ETag cannot overwrite value 2. - let stale_options = ItemWriteOptions::default() - .with_precondition(Precondition::IfMatch(Etag::from(initial_etag.to_string()))); + let stale_options = + ItemWriteOptions::default().with_precondition(Precondition::IfMatch(initial_etag)); let error = fixture .container .replace_item("A", "etag-1", item("etag-1", "A", 3), Some(stale_options)) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs index 9cea677333d..faf6c7df6c0 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs @@ -16,7 +16,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn upsert_creates_then_updates() -> TestResult { - if !should_run("item.upsert-create-update")? { + if !should_run("item.upsert-create-update").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs index 9c2af0ece4e..3d2ad862bd3 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs @@ -16,7 +16,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn invalid_query_is_not_an_empty_feed() -> TestResult { - if !should_run("query.invalid-syntax")? { + if !should_run("query.invalid-syntax").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs index 17c62ae40f8..bc68db05a4f 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs @@ -15,7 +15,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn parameterized_query_filters_and_orders() -> TestResult { - if !should_run("query.parameterized-filter")? { + if !should_run("query.parameterized-filter").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { @@ -26,6 +26,11 @@ async fn parameterized_query_filters_and_orders() -> TestResult { value.score = Some(score); fixture.container.create_item("A", &id, value, None).await?; } + let literal_id = "it's-2\" OR 1=1 --"; + fixture + .container + .create_item("A", literal_id, item(literal_id, "A", 0), None) + .await?; // Bind values as parameters and restrict execution to partition A. let query = Query::from( @@ -47,6 +52,17 @@ async fn parameterized_query_filters_and_orders() -> TestResult { .collect::>(), ["item-2", "item-3"] ); + + // Quotes and predicate syntax remain literal parameter data rather than query text. + let literal_query = + Query::from("SELECT * FROM c WHERE c.id = @id").with_parameter("@id", literal_id)?; + let literal_items: Vec = fixture + .container + .query_items::(literal_query, FeedScope::partition("A"), None) + .await? + .try_collect() + .await?; + assert_eq!(literal_items, [item(literal_id, "A", 0)]); Ok(()) }) .await diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs index 8d8afd1b9e0..2ef9e87ae58 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs @@ -3,12 +3,15 @@ use azure_core::http::StatusCode; use azure_data_cosmos::{ - diagnostics::DiagnosticsContext, + diagnostics::{DiagnosticsContext, TransportKind}, options::{ContentResponseOnWrite, ItemWriteOptions, OperationOptions}, }; use serde::{Deserialize, Serialize}; -use crate::e2e_test_cases::{catalog::selected_profile_for, fixture::TestResult}; +use crate::e2e_test_cases::{ + catalog::{required_capabilities_for, selected_profile_for, Capability, Profile}, + fixture::TestResult, +}; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub(super) struct Item { @@ -34,15 +37,8 @@ pub(super) fn write_options_with_content() -> ItemWriteOptions { ItemWriteOptions::default().with_operation_options(operation) } -pub(super) fn hosted_only() -> bool { - cfg!(any( - test_category = "emulator_inmemory", - test_category = "e2e" - )) -} - -pub(super) fn should_run(scenario_id: &str) -> TestResult { - let Some(profile) = selected_profile_for(scenario_id)? else { +pub(super) async fn should_run(scenario_id: &str) -> TestResult { + let Some(profile) = selected_scenario_profile(scenario_id).await? else { return Ok(false); }; if profile.accounts.len() != 1 || profile.runtimes.len() != 1 || profile.clients.len() != 1 { @@ -70,6 +66,91 @@ pub(super) fn should_run(scenario_id: &str) -> TestResult { Ok(true) } +pub(super) async fn selected_scenario_profile(scenario_id: &str) -> TestResult> { + init_test_tracing(); + let Some(profile) = selected_profile_for(scenario_id)? else { + let selected = std::env::var("AZURE_COSMOS_E2E_PROFILE") + .unwrap_or_else(|_| "hostedEmulatorSmoke".to_owned()); + eprintln!("SKIP {scenario_id}: profile '{selected}' does not select it"); + return Ok(None); + }; + enforce_required_capabilities(scenario_id).await?; + Ok(Some(profile)) +} + +fn init_test_tracing() { + let filter = std::env::var("RUST_LOG") + .map(tracing_subscriber::EnvFilter::new) + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::from_default_env()); + let _ = tracing_subscriber::fmt::fmt() + .with_env_filter(filter) + .try_init(); +} + +async fn enforce_required_capabilities(scenario_id: &str) -> TestResult { + let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; + let response = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()? + .get(url::Url::parse(&management_endpoint)?.join("capabilities")?) + .send() + .await? + .error_for_status()?; + let capabilities: CapabilityDocument = serde_json::from_slice(&response.bytes().await?)?; + let backend = match std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR") + .ok() + .as_deref() + { + Some("inmemory-v1") => "hostedEmulatorGatewayV1", + Some("inmemory-v2") => "hostedEmulatorGatewayV2", + Some(flavor) => { + return Err(format!( + "E2E scenario '{scenario_id}' does not support emulator flavor '{flavor}'" + ) + .into()) + } + None if capabilities.protocols.gateway_v2 => "hostedEmulatorGatewayV2", + None => "hostedEmulatorGatewayV1", + }; + let requirements = required_capabilities_for(scenario_id, backend)?; + if requirements.is_empty() { + return Ok(()); + } + if capabilities.api_version != 1 { + return Err(format!( + "scenario '{scenario_id}' requires capabilities API version 1, got {}", + capabilities.api_version + ) + .into()); + } + for requirement in requirements { + let available = match requirement { + Capability::Capabilities => true, + Capability::GatewayV2 => capabilities.protocols.gateway_v2, + }; + if !available { + return Err(format!( + "required capability '{requirement:?}' is unavailable for scenario '{scenario_id}'" + ) + .into()); + } + } + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CapabilityDocument { + api_version: u32, + protocols: ProtocolCapabilities, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolCapabilities { + gateway_v2: bool, +} + pub(super) fn assert_critical_diagnostics( diagnostics: &DiagnosticsContext, operation_name: &str, @@ -84,4 +165,18 @@ pub(super) fn assert_critical_diagnostics( Some(status_code) ); assert!(diagnostics.request_count() >= 1); + let expected_transport = match std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR").as_deref() { + Ok("inmemory-v1") => Some(TransportKind::Gateway), + Ok("inmemory-v2") => Some(TransportKind::GatewayV2), + _ => None, + }; + if let Some(expected_transport) = expected_transport { + assert!( + diagnostics + .requests() + .iter() + .all(|request| request.transport_kind() == expected_transport), + "completed requests must use {expected_transport:?}" + ); + } } diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs index de63c71509f..1ed2c08937a 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs @@ -451,9 +451,11 @@ pub(crate) async fn execute_operation_pipeline( .unwrap_or(ReadConsistencyStrategy::Default); let effective_consistency = resolve_effective_consistency(read_consistency_strategy, account_default_consistency); + let session_consistency_strategy = + session_consistency_strategy_for_operation(operation, read_consistency_strategy); let session_consistency_active = partition_key_range_cache_enabled && !session_capturing_disabled - && read_consistency_strategy.is_session_effective(account_default_consistency); + && session_consistency_strategy.is_session_effective(account_default_consistency); // Rule 4 (RCS validation): GlobalStrong is // valid only on reads against accounts whose default consistency is Strong. @@ -466,13 +468,7 @@ pub(crate) async fn execute_operation_pipeline( ) && operation.is_read_only() && account_default_consistency != DefaultConsistencyLevel::Strong { - return Err(crate::error::CosmosError::builder() - .with_status(crate::error::CosmosStatus::CLIENT_BAD_REQUEST) - .with_message( - "ReadConsistencyStrategy::GlobalStrong is only valid against accounts whose \ - default consistency level is Strong", - ) - .build()); + return Err(global_strong_account_validation_error(diagnostics)); } let max_session_retries = options .max_session_retry_count() @@ -591,9 +587,14 @@ pub(crate) async fn execute_operation_pipeline( attempt_read_consistency_strategy, account_default_consistency, ); + let attempt_session_consistency_strategy = session_consistency_strategy_for_operation( + operation, + attempt_read_consistency_strategy, + ); let attempt_session_consistency_active = partition_key_range_cache_enabled && !session_capturing_disabled - && attempt_read_consistency_strategy.is_session_effective(account_default_consistency); + && attempt_session_consistency_strategy + .is_session_effective(account_default_consistency); // Emit one structured debug record per attempt with the chosen // routing decision. Tests and SREs filter on this to verify which @@ -4362,6 +4363,32 @@ async fn execute_hedged( } } +fn session_consistency_strategy_for_operation( + operation: &CosmosOperation, + read_consistency_strategy: ReadConsistencyStrategy, +) -> ReadConsistencyStrategy { + if operation.is_read_only() { + read_consistency_strategy + } else { + ReadConsistencyStrategy::Default + } +} + +fn global_strong_account_validation_error( + mut diagnostics: DiagnosticsContextBuilder, +) -> crate::error::CosmosError { + let status = crate::error::CosmosStatus::CLIENT_BAD_REQUEST; + diagnostics.set_operation_status(status.status_code(), status.sub_status()); + crate::error::CosmosError::builder() + .with_status(status) + .with_message( + "ReadConsistencyStrategy::GlobalStrong is only valid against accounts whose \ + default consistency level is Strong", + ) + .with_diagnostics(Arc::new(diagnostics.complete())) + .build() +} + /// Generic "both sides transient" error carried inside /// [`HedgedRaceResult::BothTransient`] when neither leg produced a /// final response and the deadline has not elapsed. The surrounding @@ -4729,6 +4756,28 @@ mod tests { )); } + #[test] + fn writes_ignore_read_consistency_strategy_for_session_capture() { + let item = ItemReference::from_name(&test_container(), PartitionKey::from("pk1"), "doc1"); + let write = CosmosOperation::create_item(item.clone()).with_body(b"{}".to_vec()); + let read = CosmosOperation::read_item(item); + + assert_eq!( + super::session_consistency_strategy_for_operation( + &write, + crate::options::ReadConsistencyStrategy::LatestCommitted, + ), + crate::options::ReadConsistencyStrategy::Default + ); + assert_eq!( + super::session_consistency_strategy_for_operation( + &read, + crate::options::ReadConsistencyStrategy::LatestCommitted, + ), + crate::options::ReadConsistencyStrategy::LatestCommitted + ); + } + #[test] fn patch_read_routing_hint_is_restored_after_generic_failover_reset() { use crate::driver::pipeline::components::SessionRetryRouting; @@ -10278,6 +10327,27 @@ mod tests { ) } + #[test] + fn global_strong_account_validation_preserves_zero_request_diagnostics() { + let error = super::global_strong_account_validation_error(test_diagnostics()); + + assert_eq!( + error.status(), + crate::error::CosmosStatus::CLIENT_BAD_REQUEST + ); + assert!(error.response().is_none()); + let diagnostics = error + .diagnostics() + .expect("client-side validation must preserve diagnostics"); + assert_eq!(diagnostics.request_count(), 0); + assert_eq!( + diagnostics + .effective_status() + .map(|status| status.status_code()), + Some(azure_core::http::StatusCode::BadRequest) + ); + } + #[test] fn enforce_deadline_none_is_ok() { let options = empty_options_view(); diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs b/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs index 60823dfc131..69cfd1c0882 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs @@ -28,8 +28,9 @@ use crate::models::DefaultConsistencyLevel; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum ReadConsistencyStrategy { - /// Use the default read behavior for the consistency level applied to the operation, - /// the client, or the account. No RCS header / token is emitted on the wire. + /// Reset to the default consistency-level or account behavior at the layer where this + /// value is explicitly configured. Unlike an absent value, an explicit `Default` masks + /// lower-precedence read consistency strategies. No RCS header / token is emitted. Default, /// Eventual consistency guarantees that reads will return a subset of writes. diff --git a/sdk/cosmos/docs/specs/0011-gateway-v2.md b/sdk/cosmos/docs/specs/0011-gateway-v2.md index d4c5fbe8a8c..52d16017c20 100644 --- a/sdk/cosmos/docs/specs/0011-gateway-v2.md +++ b/sdk/cosmos/docs/specs/0011-gateway-v2.md @@ -376,7 +376,11 @@ Sources, highest precedence first: 4. Client-level `ConsistencyLevel` 5. Account default consistency (no header / no token emitted; backend applies its default) -`ReadConsistencyStrategy::Default` at any level is a pass-through — falls through to the next source. Write operations skip steps 1 and 3 entirely (RCS is read-only); writes resolve from steps 2/4/5. +An absent `ReadConsistencyStrategy` value falls through to the next source. An explicitly +configured `ReadConsistencyStrategy::Default` terminates strategy resolution at that layer and +resets the read to consistency-level or account behavior; it masks lower-precedence RCS values +without emitting an RCS header or token. Write operations skip steps 1 and 3 entirely (RCS is +read-only); writes resolve from steps 2/4/5. ##### Dual-header rejection rule diff --git a/sdk/cosmos/e2e-consistency-matrix.json b/sdk/cosmos/e2e-consistency-matrix.json index 1fe87152a19..a8f21643285 100644 --- a/sdk/cosmos/e2e-consistency-matrix.json +++ b/sdk/cosmos/e2e-consistency-matrix.json @@ -2,6 +2,8 @@ "displayNames": { "inmemory-v1": "gateway_v1", "inmemory-v2": "gateway_v2", + "lifecycleConsistencyMatrix": "consistency", + "unset": "default", "strong": "strong_2_regions", "boundedStaleness": "bounded_staleness_2_regions_delayed", "session": "session_2_regions_delayed", diff --git a/sdk/cosmos/e2e-profile-matrix.json b/sdk/cosmos/e2e-profile-matrix.json deleted file mode 100644 index 159c59b04d3..00000000000 --- a/sdk/cosmos/e2e-profile-matrix.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "displayNames": { - "inmemory-v1": "gateway_v1", - "inmemory-v2": "gateway_v2", - "strongTwoRegion": "strong_2_regions", - "boundedStalenessTwoRegionDelayed": "bounded_staleness_2_regions_delayed", - "sessionTwoRegionDelayed": "session_2_regions_delayed", - "consistentPrefixTwoRegionDelayed": "consistent_prefix_2_regions_delayed", - "eventualTwoRegionDelayed": "eventual_2_regions_delayed" - }, - "matrix": { - "Agent": { - "ubuntu": { - "OSVmImage": "env:LINUXVMIMAGE", - "Pool": "env:LINUXPOOL" - } - }, - "RustToolchainName": [ - "stable" - ], - "AZURE_COSMOS_EMULATOR_FLAVOR": [ - "inmemory-v1", - "inmemory-v2" - ], - "AZURE_COSMOS_E2E_PROFILE": [ - "strongTwoRegion", - "boundedStalenessTwoRegionDelayed", - "sessionTwoRegionDelayed", - "consistentPrefixTwoRegionDelayed", - "eventualTwoRegionDelayed" - ] - } -} diff --git a/sdk/cosmos/e2e-read-consistency-override-matrix.json b/sdk/cosmos/e2e-read-consistency-override-matrix.json index 6f8ffb6b358..2769b381bd5 100644 --- a/sdk/cosmos/e2e-read-consistency-override-matrix.json +++ b/sdk/cosmos/e2e-read-consistency-override-matrix.json @@ -2,7 +2,8 @@ "displayNames": { "inmemory-v1": "gateway_v1", "inmemory-v2": "gateway_v2", - "unset": "unset", + "readConsistencyOverrideMatrix": "rcs_override", + "unset": "default", "eventual": "eventual", "session": "session", "latestCommitted": "latest_committed" diff --git a/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json b/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json deleted file mode 100644 index 6014121ed8f..00000000000 --- a/sdk/cosmos/e2e_tests/profiles/legacyGatewayV1.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "$schema": "../schema/profile.v1.json", - "specVersion": "1.0", - "id": "legacyGatewayV1", - "accounts": [ - { - "id": "sessionSingleRegion", - "writeMode": "single", - "consistency": "session", - "regions": [ - { - "name": "East US" - } - ], - "replication": { - "minDelayMs": 0, - "maxDelayMs": 0 - }, - "perPartitionFailover": false - } - ], - "runtimes": [ - { - "id": "gatewayV1PpcbDisabled", - "gatewayV2": "disabled", - "ppcb": "disabled" - } - ], - "clients": [ - { - "id": "textAccountOrder", - "binaryEncoding": "disabled", - "routing": "accountOrder" - } - ] -} diff --git a/sdk/cosmos/e2e_tests/profiles/targetDefault.json b/sdk/cosmos/e2e_tests/profiles/targetDefault.json deleted file mode 100644 index aa3c493cc30..00000000000 --- a/sdk/cosmos/e2e_tests/profiles/targetDefault.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "../schema/profile.v1.json", - "specVersion": "1.0", - "id": "targetDefault", - "accounts": [ - { - "id": "sessionTwoRegionDelayed", - "writeMode": "single", - "consistency": "session", - "regions": [ - { - "name": "East US" - }, - { - "name": "West US" - } - ], - "replication": { - "minDelayMs": 500, - "maxDelayMs": 500 - }, - "perPartitionFailover": true - } - ], - "runtimes": [ - { - "id": "gatewayV2Ppcb", - "gatewayV2": "enabled", - "ppcb": "enabled" - } - ], - "clients": [ - { - "id": "binaryProximity", - "binaryEncoding": "enabled", - "routing": "proximity" - } - ] -} diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 index ed717c7acbb..3c4c5cf8ab9 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 @@ -28,19 +28,19 @@ function Test-CosmosE2eScenarioDocuments { function New-CosmosE2eEmulatorConfig { param( [Parameter(Mandatory)] - [string] $Profile, + [string] $ProfileId, [Parameter(Mandatory)] [bool] $GatewayV2Enabled ) - if ($Profile -notmatch '^[a-zA-Z][a-zA-Z0-9]*$') { - throw "Invalid AZURE_COSMOS_E2E_PROFILE value '$Profile'." + if ($ProfileId -notmatch '^[a-zA-Z][a-zA-Z0-9]*$') { + throw "Invalid AZURE_COSMOS_E2E_PROFILE value '$ProfileId'." } $e2eTestRoot = ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', 'e2e_tests')) - $profilePath = ([System.IO.Path]::Combine($e2eTestRoot, 'profiles', "$Profile.json")) + $profilePath = ([System.IO.Path]::Combine($e2eTestRoot, 'profiles', "$ProfileId.json")) if (-not (Test-Path $profilePath)) { - throw "E2E profile '$Profile' does not exist at '$profilePath'." + throw "E2E profile '$ProfileId' does not exist at '$profilePath'." } $profileDocument = Get-Content $profilePath -Raw | ConvertFrom-Json $accountDefinitions = @($profileDocument.accounts) @@ -51,10 +51,10 @@ function New-CosmosE2eEmulatorConfig { @($accountDefinitions[0]) } else { - throw "AZURE_COSMOS_E2E_ACCOUNT is required for profile '$Profile'." + throw "AZURE_COSMOS_E2E_ACCOUNT is required for profile '$ProfileId'." } if ($accountDefinition.Count -ne 1) { - throw "Profile '$Profile' does not contain exactly one account named '$env:AZURE_COSMOS_E2E_ACCOUNT'." + throw "Profile '$ProfileId' does not contain exactly one account named '$env:AZURE_COSMOS_E2E_ACCOUNT'." } $accountDefinition = $accountDefinition[0] $regions = @($accountDefinition.regions | ForEach-Object { @@ -69,7 +69,7 @@ function New-CosmosE2eEmulatorConfig { }) $configuration = [ordered]@{ account = [ordered]@{ - id = "e2e-$Profile-$($accountDefinition.id)" + id = "e2e-$ProfileId-$($accountDefinition.id)" writeMode = [string]$accountDefinition.writeMode consistency = [string]$accountDefinition.consistency perPartitionFailover = [bool]$accountDefinition.perPartitionFailover @@ -85,9 +85,12 @@ function New-CosmosE2eEmulatorConfig { databases = @() } $mode = if ($GatewayV2Enabled) { 'v2' } else { 'v1' } - $path = ([System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "azure-cosmos-e2e-$Profile-$($accountDefinition.id)-$mode.json")) + $path = ([System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "azure-cosmos-e2e-$ProfileId-$($accountDefinition.id)-$mode.json")) $configuration | ConvertTo-Json -Depth 10 | Set-Content $path - return $path + return [pscustomobject]@{ + Path = $path + AccountId = $configuration.account.id + } } if (-not $env:AZURE_COSMOS_E2E_TESTS_VALIDATED) { @@ -136,6 +139,11 @@ if ($env:AZURE_COSMOS_FUZZ -eq '1' -and -not $env:AZURE_COSMOS_FUZZ_RAN) { # Hosted in-memory emulator path. The additional CI matrix sets one of the two # flavors below so the existing emulator suites run against both Gateway V1 # and Gateway 2.0 over cleartext HTTP/2. +if ($env:AZURE_COSMOS_E2E_PROFILE -and + $env:AZURE_COSMOS_EMULATOR_FLAVOR -notin @('inmemory-v1', 'inmemory-v2')) { + throw 'AZURE_COSMOS_E2E_PROFILE requires AZURE_COSMOS_EMULATOR_FLAVOR to be inmemory-v1 or inmemory-v2.' +} + if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $repoRoot = (Resolve-Path ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', '..', '..'))).Path $configuration = if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -eq 'inmemory-v2') { @@ -146,10 +154,20 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { } $ready = $false $expectedGateway20 = $env:AZURE_COSMOS_EMULATOR_FLAVOR -eq 'inmemory-v2' + $expectedAccountId = $null if ($env:AZURE_COSMOS_E2E_PROFILE) { - $configuration = New-CosmosE2eEmulatorConfig ` - -Profile $env:AZURE_COSMOS_E2E_PROFILE ` + $e2eConfiguration = New-CosmosE2eEmulatorConfig ` + -ProfileId $env:AZURE_COSMOS_E2E_PROFILE ` -GatewayV2Enabled $expectedGateway20 + $configuration = $e2eConfiguration.Path + $expectedAccountId = $e2eConfiguration.AccountId + } + else { + $e2eConfiguration = New-CosmosE2eEmulatorConfig ` + -ProfileId 'hostedEmulatorSmoke' ` + -GatewayV2Enabled $expectedGateway20 + $configuration = $e2eConfiguration.Path + $expectedAccountId = $e2eConfiguration.AccountId } $managementEndpoint = $env:AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT $accountEndpoint = $env:AZURE_COSMOS_INMEMORY_ACCOUNT_ENDPOINT @@ -159,6 +177,12 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop $health = $response.Content | ConvertFrom-Json $ready = $response.StatusCode -eq 200 -and $health.gateway20Enabled -eq $expectedGateway20 + if ($ready -and $expectedAccountId) { + $accountUrl = ([System.Uri]::new([System.Uri]$managementEndpoint, 'account')).AbsoluteUri + $accountResponse = Invoke-WebRequest -Uri $accountUrl -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop + $account = $accountResponse.Content | ConvertFrom-Json + $ready = $accountResponse.StatusCode -eq 200 -and $account.id -eq $expectedAccountId + } } catch { $ready = $false From 76883608422d882163a5fd8cdcf74380c407e066 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Fri, 11 Sep 2026 21:21:28 +0000 Subject: [PATCH 06/19] Addressing Code review feedback --- eng/scripts/Test-Packages.ps1 | 86 ++++++++++--------- .../eng/scripts/Invoke-CosmosTestCleanup.ps1 | 6 +- .../eng/scripts/Invoke-CosmosTestSetup.ps1 | 33 +++++-- 3 files changed, 78 insertions(+), 47 deletions(-) diff --git a/eng/scripts/Test-Packages.ps1 b/eng/scripts/Test-Packages.ps1 index 6c516a5e970..7fc9912586e 100755 --- a/eng/scripts/Test-Packages.ps1 +++ b/eng/scripts/Test-Packages.ps1 @@ -51,7 +51,7 @@ function Invoke-CargoTest ( $message += " For more information see the pipeline Tests tab." } LogError $message - exit $LASTEXITCODE + throw "$message Cargo exited with code $LASTEXITCODE." } } @@ -100,47 +100,55 @@ foreach ($package in $packagesToTest) { foreach ($package in $packagesToTest) { $packageDirectory = ([System.IO.Path]::Combine($RepoRoot, $package.DirectoryPath)) + $cleanupScript = ([System.IO.Path]::Combine($packageDirectory, 'Test-Cleanup.ps1')) - $setupScript = ([System.IO.Path]::Combine($packageDirectory, 'Test-Setup.ps1')) - if (Test-Path $setupScript) { - Write-Host "`n`nRunning test setup script for package: '$($package.Name)'`n" - Invoke-LoggedCommand $setupScript -GroupOutput - if (!$? -ne 0) { - LogError "Test setup script failed for package: '$($package.Name)'" - exit 1 + try { + $setupScript = ([System.IO.Path]::Combine($packageDirectory, 'Test-Setup.ps1')) + if (Test-Path $setupScript) { + Write-Host "`n`nRunning test setup script for package: '$($package.Name)'`n" + Invoke-LoggedCommand $setupScript -GroupOutput -DoNotExitOnFailedExitCode + if ($LASTEXITCODE) { + throw "Test setup script failed for package '$($package.Name)' with exit code $LASTEXITCODE." + } } - } - - Write-Host "`n`nTesting package: '$($package.Name)'`n" - - $buildCommand = (@('cargo', 'build') + $cargoFeatureArgs + @('--keep-going')) -join ' ' - Invoke-LoggedCommand $buildCommand -GroupOutput - Write-Host "`n`n" - - $manifestPath = [System.IO.Path]::Combine($packageDirectory, 'Cargo.toml') - $timestamp = Get-Date -Format "yyyyMMdd-HHmmss-fff" - - $docTestOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-doctest-$timestamp.json")) - Invoke-CargoTest ` - -TestParams "--doc" ` - -PackageName $package.Name ` - -ManifestPath $manifestPath ` - -OutputFile $docTestOutput - $allTargetsOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-alltargets-$timestamp.json")) - Invoke-CargoTest ` - -TestParams "--lib --bins --tests --examples" ` - -PackageName $package.Name ` - -ManifestPath $manifestPath ` - -OutputFile $allTargetsOutput + Write-Host "`n`nTesting package: '$($package.Name)'`n" - $benchCommand = (@('cargo', 'test', '--benches', '--manifest-path', $manifestPath) + $cargoFeatureArgs + @('--no-fail-fast')) -join ' ' - Invoke-LoggedCommand $benchCommand -GroupOutput - - $cleanupScript = ([System.IO.Path]::Combine($packageDirectory, 'Test-Cleanup.ps1')) - if (Test-Path $cleanupScript) { - Write-Host "`n`nRunning test cleanup script for package: '$($package.Name)'`n" - Invoke-LoggedCommand $cleanupScript -GroupOutput -DoNotExitOnFailedExitCode - # We ignore the exit code of the cleanup script. + $buildCommand = (@('cargo', 'build') + $cargoFeatureArgs + @('--keep-going')) -join ' ' + Invoke-LoggedCommand $buildCommand -GroupOutput -DoNotExitOnFailedExitCode + if ($LASTEXITCODE) { + throw "Build failed for package '$($package.Name)' with exit code $LASTEXITCODE." + } + Write-Host "`n`n" + + $manifestPath = [System.IO.Path]::Combine($packageDirectory, 'Cargo.toml') + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss-fff" + + $docTestOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-doctest-$timestamp.json")) + Invoke-CargoTest ` + -TestParams "--doc" ` + -PackageName $package.Name ` + -ManifestPath $manifestPath ` + -OutputFile $docTestOutput + + $allTargetsOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-alltargets-$timestamp.json")) + Invoke-CargoTest ` + -TestParams "--lib --bins --tests --examples" ` + -PackageName $package.Name ` + -ManifestPath $manifestPath ` + -OutputFile $allTargetsOutput + + $benchCommand = (@('cargo', 'test', '--benches', '--manifest-path', $manifestPath) + $cargoFeatureArgs + @('--no-fail-fast')) -join ' ' + Invoke-LoggedCommand $benchCommand -GroupOutput -DoNotExitOnFailedExitCode + if ($LASTEXITCODE) { + throw "Benchmark tests failed for package '$($package.Name)' with exit code $LASTEXITCODE." + } + } + finally { + if (Test-Path $cleanupScript) { + Write-Host "`n`nRunning test cleanup script for package: '$($package.Name)'`n" + Invoke-LoggedCommand $cleanupScript -GroupOutput -DoNotExitOnFailedExitCode + # We ignore the exit code of the cleanup script. + } } } diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 index 912cebabf6b..ccdb24eb9d0 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 @@ -12,12 +12,15 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $hostProcess = Get-Process -Id ([int]$env:AZURE_COSMOS_INMEMORY_EMULATOR_PID) -ErrorAction SilentlyContinue } else { - $hostProcess = Get-Process azure_data_cosmos_emulator -ErrorAction SilentlyContinue + $hostProcess = $null } if ($hostProcess) { $hostProcess | Stop-Process -Force -ErrorAction SilentlyContinue $hostProcess | Wait-Process -Timeout 10 -ErrorAction SilentlyContinue } + if ($env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY) { + Remove-Item $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY -Recurse -Force -ErrorAction SilentlyContinue + } } elseif ($IsWindows) { @@ -98,6 +101,7 @@ $env:AZURE_COSMOS_EMULATOR_HOST = $null $env:AZURE_COSMOS_INMEMORY_EMULATOR_PID = $null $env:AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT = $null $env:AZURE_COSMOS_INMEMORY_ACCOUNT_ENDPOINT = $null +$env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY = $null # Remove any --cfg=test_category="..." flag added by Test-Setup.ps1 or COSMOS_RUSTFLAGS. # The next package's setup will re-add the correct flag from COSMOS_RUSTFLAGS # (or from AZURE_COSMOS_EMULATOR_FLAVOR=vnext when running the vnext stage). diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 index 3c4c5cf8ab9..c14e038a8b8 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 @@ -31,7 +31,10 @@ function New-CosmosE2eEmulatorConfig { [string] $ProfileId, [Parameter(Mandatory)] - [bool] $GatewayV2Enabled + [bool] $GatewayV2Enabled, + + [Parameter(Mandatory)] + [string] $OutputDirectory ) if ($ProfileId -notmatch '^[a-zA-Z][a-zA-Z0-9]*$') { @@ -85,7 +88,7 @@ function New-CosmosE2eEmulatorConfig { databases = @() } $mode = if ($GatewayV2Enabled) { 'v2' } else { 'v1' } - $path = ([System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "azure-cosmos-e2e-$ProfileId-$($accountDefinition.id)-$mode.json")) + $path = ([System.IO.Path]::Combine($OutputDirectory, "azure-cosmos-e2e-$ProfileId-$($accountDefinition.id)-$mode.json")) $configuration | ConvertTo-Json -Depth 10 | Set-Content $path return [pscustomobject]@{ Path = $path @@ -146,6 +149,16 @@ if ($env:AZURE_COSMOS_E2E_PROFILE -and if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $repoRoot = (Resolve-Path ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', '..', '..'))).Path + $runDirectory = if ($env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY) { + $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY + } + else { + ([System.IO.Path]::Combine( + [System.IO.Path]::GetTempPath(), + "azure-data-cosmos-emulator-$([System.Guid]::NewGuid().ToString('N'))")) + } + New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null + $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY = $runDirectory $configuration = if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -eq 'inmemory-v2') { [System.IO.Path]::Combine($repoRoot, 'sdk', 'cosmos', 'azure_data_cosmos_emulator', 'config', 'ci-gateway-v2.json') } @@ -158,14 +171,16 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { if ($env:AZURE_COSMOS_E2E_PROFILE) { $e2eConfiguration = New-CosmosE2eEmulatorConfig ` -ProfileId $env:AZURE_COSMOS_E2E_PROFILE ` - -GatewayV2Enabled $expectedGateway20 + -GatewayV2Enabled $expectedGateway20 ` + -OutputDirectory $runDirectory $configuration = $e2eConfiguration.Path $expectedAccountId = $e2eConfiguration.AccountId } else { $e2eConfiguration = New-CosmosE2eEmulatorConfig ` -ProfileId 'hostedEmulatorSmoke' ` - -GatewayV2Enabled $expectedGateway20 + -GatewayV2Enabled $expectedGateway20 ` + -OutputDirectory $runDirectory $configuration = $e2eConfiguration.Path $expectedAccountId = $e2eConfiguration.AccountId } @@ -190,7 +205,11 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { } if (-not $ready) { - Get-Process azure_data_cosmos_emulator -ErrorAction SilentlyContinue | Stop-Process -Force + if ($env:AZURE_COSMOS_INMEMORY_EMULATOR_PID) { + Get-Process -Id ([int]$env:AZURE_COSMOS_INMEMORY_EMULATOR_PID) -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + $env:AZURE_COSMOS_INMEMORY_EMULATOR_PID = $null + } } if (-not $ready) { @@ -212,8 +231,8 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { 'azure_data_cosmos_emulator' } $executable = [System.IO.Path]::Combine($repoRoot, 'target', 'debug', $executableName) - $stdout = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'azure-data-cosmos-emulator.out.log') - $stderr = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'azure-data-cosmos-emulator.err.log') + $stdout = [System.IO.Path]::Combine($runDirectory, 'stdout.log') + $stderr = [System.IO.Path]::Combine($runDirectory, 'stderr.log') Remove-Item $stdout, $stderr -Force -ErrorAction SilentlyContinue LogGroupStart "Starting hosted Cosmos DB in-memory emulator" From aba0f5de7a3865615287b8fd40813fe6da3989cf Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Fri, 11 Sep 2026 21:29:30 +0000 Subject: [PATCH 07/19] Update catalog.rs --- .../tests/e2e_test_cases/catalog.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs index c72c6c56230..c9e687902cd 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs @@ -314,6 +314,9 @@ pub(super) fn required_capabilities_for( } pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { + // Full JSON Schema evaluation is owned by Test-CosmosE2eScenarioDocuments in + // Invoke-CosmosTestSetup.ps1. Keep these repository-native semantic checks aligned with + // schema constraints that they intentionally duplicate. for (name, schema) in [("scenario", SCENARIO_SCHEMA), ("profile", PROFILE_SCHEMA)] { let schema: Value = serde_json::from_str(schema) .map_err(|error| format!("{name} schema is not JSON: {error}"))?; @@ -370,6 +373,7 @@ pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { } for account in &profile.accounts { if account.regions.is_empty() + || account.regions.iter().any(|region| region.name.is_empty()) || account.replication.min_delay_ms > account.replication.max_delay_ms || !matches!(account.write_mode.as_str(), "single" | "multi") || !matches!( @@ -645,7 +649,19 @@ fn valid_scenario_id(value: &str) -> bool { let mut segments = value.split('.'); let first = segments.next(); let rest: Vec<_> = segments.collect(); - first.is_some_and(valid_slug) && !rest.is_empty() && rest.into_iter().all(valid_slug) + first.is_some_and(valid_first_scenario_segment) + && !rest.is_empty() + && rest.into_iter().all(valid_slug) +} + +fn valid_first_scenario_segment(value: &str) -> bool { + value + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value + .chars() + .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) } fn valid_slug(value: &str) -> bool { @@ -657,3 +673,15 @@ fn valid_slug(value: &str) -> bool { character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' }) } + +#[cfg(test)] +mod tests { + use super::valid_scenario_id; + + #[test] + fn scenario_id_matches_schema_segment_rules() { + assert!(valid_scenario_id("changefeed.all-versions")); + assert!(!valid_scenario_id("change-feed.all-versions")); + assert!(!valid_scenario_id("changefeed.")); + } +} From 401fa86974531fe0323dd06d05becb4d236158f3 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Fri, 11 Sep 2026 21:35:03 +0000 Subject: [PATCH 08/19] Applying code review feedback --- .../tests/e2e_test_cases/item_lifecycle.rs | 2 +- .../tests/e2e_test_cases/mod.rs | 28 +++++++++-- .../e2e_tests/implementations/rust.json | 2 +- .../eng/scripts/Invoke-CosmosTestSetup.ps1 | 50 ++++++++++++------- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index c77c9a7b102..c4726516a5e 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -36,7 +36,7 @@ const RETRY_DELAY: Duration = Duration::from_millis(50); not(any(test_category = "emulator_inmemory", test_category = "e2e")), ignore = "requires the externally hosted in-memory emulator" )] -async fn item_lifecycle() -> TestResult { +async fn crud_lifecycle() -> TestResult { let Some(profile) = selected_scenario_profile("item.lifecycle").await? else { return Ok(()); }; diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs index c116ee9721b..148a7a5cf26 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs @@ -7,11 +7,10 @@ mod catalog; mod diagnostics_success_and_error; mod fixture; mod item_create_conflict; +mod item_lifecycle; mod item_not_found; mod item_optimistic_concurrency; mod item_upsert; -#[path = "item_lifecycle.rs"] -mod lifecycle; mod query_invalid_syntax; mod query_parameterized_filter; mod support; @@ -19,7 +18,7 @@ mod support; const IMPLEMENTED_TESTS: &[&str] = &[ "capabilities::capability_document_is_versioned", "bootstrap_primary::bootstrap_primary_endpoint", - "lifecycle::item_lifecycle", + "item_lifecycle::crud_lifecycle", "item_upsert::upsert_creates_then_updates", "item_create_conflict::duplicate_create_preserves_original", "item_not_found::not_found_does_not_cross_partition_keys", @@ -33,3 +32,26 @@ const IMPLEMENTED_TESTS: &[&str] = &[ fn e2e_scenario_catalog_is_valid() { catalog::validate_catalog(IMPLEMENTED_TESTS).expect("E2E scenario catalog must be valid"); } + +#[test] +fn implementation_registry_names_compiled_tests() { + let executable = std::env::current_exe().expect("current E2E test executable must be known"); + let output = std::process::Command::new(executable) + .arg("--list") + .output() + .expect("E2E test executable must support libtest --list"); + assert!( + output.status.success(), + "listing compiled E2E tests failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let listed = String::from_utf8(output.stdout).expect("libtest list must be UTF-8"); + for test in IMPLEMENTED_TESTS { + assert!( + listed.lines().any(|line| { + line.strip_suffix(": test") == Some(format!("e2e_test_cases::{test}").as_str()) + }), + "implementation registry entry '{test}' does not name a compiled E2E test" + ); + } +} diff --git a/sdk/cosmos/e2e_tests/implementations/rust.json b/sdk/cosmos/e2e_tests/implementations/rust.json index 7df1b265512..60350fcd727 100644 --- a/sdk/cosmos/e2e_tests/implementations/rust.json +++ b/sdk/cosmos/e2e_tests/implementations/rust.json @@ -15,7 +15,7 @@ }, { "id": "item.lifecycle", - "test": "lifecycle::item_lifecycle", + "test": "item_lifecycle::crud_lifecycle", "status": "active" }, { diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 index c14e038a8b8..28f33dbe333 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 @@ -13,15 +13,31 @@ function Test-CosmosE2eScenarioDocuments { $scenarioSchema = ([System.IO.Path]::Combine($e2eTestRoot, 'schema', 'scenario.v1.json')) $profileSchema = ([System.IO.Path]::Combine($e2eTestRoot, 'schema', 'profile.v1.json')) - Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'scenarios')) -Recurse -Filter '*.json' | ForEach-Object { + $scenarioDocuments = @(Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'scenarios')) -Recurse -Filter '*.json' | ForEach-Object { if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $scenarioSchema)) { throw "Cosmos E2E scenario failed schema validation: $($_.FullName)" } - } - Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'profiles')) -Filter '*.json' | ForEach-Object { + Get-Content $_.FullName -Raw | ConvertFrom-Json + }) + $profileDocuments = @(Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'profiles')) -Filter '*.json' | ForEach-Object { if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $profileSchema)) { throw "Cosmos E2E profile failed schema validation: $($_.FullName)" } + Get-Content $_.FullName -Raw | ConvertFrom-Json + }) + + $implementationPath = ([System.IO.Path]::Combine($e2eTestRoot, 'implementations', 'rust.json')) + $implementation = Get-Content $implementationPath -Raw | ConvertFrom-Json + $scenarioIds = @($scenarioDocuments.id | Sort-Object -Unique) + $implementationIds = @($implementation.scenarios.id | Sort-Object -Unique) + if (Compare-Object $scenarioIds $implementationIds) { + throw 'Cosmos E2E scenario files and Rust implementation mappings must contain identical scenario IDs.' + } + + $profileIds = @($profileDocuments.id | Sort-Object -Unique) + $referencedProfileIds = @($scenarioDocuments.profiles | Sort-Object -Unique) + if (Compare-Object $profileIds $referencedProfileIds) { + throw 'Cosmos E2E profile files and scenario profile references must contain identical profile IDs.' } } @@ -62,7 +78,7 @@ function New-CosmosE2eEmulatorConfig { $accountDefinition = $accountDefinition[0] $regions = @($accountDefinition.regions | ForEach-Object { $region = [ordered]@{ - name = [string]$_.name + name = [string]$_.name gatewayPort = 0 } if ($GatewayV2Enabled) { @@ -71,27 +87,27 @@ function New-CosmosE2eEmulatorConfig { [pscustomobject]$region }) $configuration = [ordered]@{ - account = [ordered]@{ - id = "e2e-$ProfileId-$($accountDefinition.id)" - writeMode = [string]$accountDefinition.writeMode - consistency = [string]$accountDefinition.consistency + account = [ordered]@{ + id = "e2e-$ProfileId-$($accountDefinition.id)" + writeMode = [string]$accountDefinition.writeMode + consistency = [string]$accountDefinition.consistency perPartitionFailover = [bool]$accountDefinition.perPartitionFailover - throttling = $false - regions = $regions - replication = [ordered]@{ - minDelayMs = [uint64]$accountDefinition.replication.minDelayMs - maxDelayMs = [uint64]$accountDefinition.replication.maxDelayMs + throttling = $false + regions = $regions + replication = [ordered]@{ + minDelayMs = [uint64]$accountDefinition.replication.minDelayMs + maxDelayMs = [uint64]$accountDefinition.replication.maxDelayMs maxBufferedReplications = 10000 } } management = @{ port = 0 } - databases = @() + databases = @() } $mode = if ($GatewayV2Enabled) { 'v2' } else { 'v1' } $path = ([System.IO.Path]::Combine($OutputDirectory, "azure-cosmos-e2e-$ProfileId-$($accountDefinition.id)-$mode.json")) $configuration | ConvertTo-Json -Depth 10 | Set-Content $path return [pscustomobject]@{ - Path = $path + Path = $path AccountId = $configuration.account.id } } @@ -154,8 +170,8 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { } else { ([System.IO.Path]::Combine( - [System.IO.Path]::GetTempPath(), - "azure-data-cosmos-emulator-$([System.Guid]::NewGuid().ToString('N'))")) + [System.IO.Path]::GetTempPath(), + "azure-data-cosmos-emulator-$([System.Guid]::NewGuid().ToString('N'))")) } New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY = $runDirectory From 7f025d5c9ba0e26bff4270d433404e77b740ff5f Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Fri, 11 Sep 2026 21:47:16 +0000 Subject: [PATCH 09/19] Apply code review feedback --- .../tests/e2e_test_cases/catalog.rs | 18 ++++++++++-- .../diagnostics_success_and_error.rs | 10 +++---- .../tests/e2e_test_cases/item_lifecycle.rs | 28 +++++++++++++++---- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs index c9e687902cd..85f1b3e80a1 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -#![allow(dead_code)] - use std::collections::{BTreeMap, BTreeSet}; use serde::Deserialize; @@ -51,6 +49,10 @@ struct Scenario { id: String, title: String, requirement: String, + #[expect( + dead_code, + reason = "typed schema metadata is validated during deserialization" + )] maturity: Maturity, precedents: Vec, profiles: Vec, @@ -69,6 +71,10 @@ enum Maturity { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct Precedent { + #[expect( + dead_code, + reason = "typed schema metadata is validated during deserialization" + )] sdk: ReferenceSdk, path: String, test: String, @@ -88,6 +94,10 @@ enum ReferenceSdk { #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Backend { applicability: Applicability, + #[expect( + dead_code, + reason = "typed schema metadata is validated during deserialization" + )] fidelity: Fidelity, reason: Option, #[serde(default)] @@ -139,6 +149,10 @@ pub struct AccountDefinition { pub consistency: String, regions: Vec, replication: ReplicationDefinition, + #[expect( + dead_code, + reason = "profile metadata is consumed by external orchestration" + )] per_partition_failover: bool, } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs index ec1e0c00dec..f3c3498117d 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs @@ -29,10 +29,10 @@ async fn diagnostics_cover_success_and_error() -> TestResult { let success = fixture.container.read_item("A", "item-1", None).await?; assert_eq!(success.status().status_code(), StatusCode::Ok); assert_critical_diagnostics(&success.diagnostics(), "read_item", StatusCode::Ok); - assert!(success - .diagnostics() - .regions_contacted() - .contains(&Region::EAST_US)); + assert_eq!( + success.diagnostics().regions_contacted(), + vec![Region::EAST_US] + ); // Error diagnostics preserve the same fields while reporting the terminal plain 404. let error = fixture @@ -53,7 +53,7 @@ async fn diagnostics_cover_success_and_error() -> TestResult { .diagnostics() .expect("service error must carry diagnostics"); assert_critical_diagnostics(&diagnostics, "read_item", StatusCode::NotFound); - assert!(diagnostics.regions_contacted().contains(&Region::EAST_US)); + assert_eq!(diagnostics.regions_contacted(), vec![Region::EAST_US]); Ok(()) }) .await diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index c4726516a5e..c9e79d1e7e1 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -119,7 +119,7 @@ async fn run_lifecycle_case( assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); - // Delete the item, then use the delete session token to verify a plain 404/0. + // Delete the item, then verify a plain 404/0 under this case's read strategy. let deleted = fixture.container.delete_item("A", &item_id, None).await?; assert_eq!(deleted.status().status_code(), StatusCode::NoContent); assert_critical_diagnostics( @@ -136,6 +136,7 @@ async fn run_lifecycle_case( &fixture.container, &item_id, delete_session_token, + read_case, &execution, ) .await?; @@ -648,14 +649,25 @@ async fn assert_item_deleted( container: &ContainerClient, item_id: &str, delete_session_token: String, + read_case: &PostCreateReadCase, execution: &str, ) -> TestResult { let mut operation = OperationOptions::default(); - operation.read_consistency_strategy = Some(ReadConsistencyStrategy::Session); + operation.read_consistency_strategy = + if read_case.expectation == ReadExpectation::RejectedBeforeTransport { + // GlobalStrong cannot verify resource state on a non-Strong account. Its rejection was + // already asserted above, so use the strongest valid deletion check for this cell. + Some(ReadConsistencyStrategy::Session) + } else { + read_case.consistency_override + }; operation.availability_strategy = Some(AvailabilityStrategy::Disabled); - let options = ItemReadOptions::default() - .with_operation_options(operation) - .with_session_token(delete_session_token); + let mut options = ItemReadOptions::default().with_operation_options(operation); + if read_case.session_token == SessionTokenBehavior::ExplicitCreateResponse + || read_case.expectation == ReadExpectation::RejectedBeforeTransport + { + options = options.with_session_token(delete_session_token); + } let deadline = tokio::time::Instant::now() + REPLICATION_TIMEOUT; loop { @@ -676,6 +688,12 @@ async fn assert_item_deleted( error.status().status_code(), error.status().sub_status().map(|value| value.value()), ) && tokio::time::Instant::now() < deadline => {} + Ok(_) + if matches!( + read_case.expectation, + ReadExpectation::EventuallySucceeds { .. } + ) && read_case.session_token == SessionTokenBehavior::Omitted + && tokio::time::Instant::now() < deadline => {} Ok(response) => { let diagnostics = response.diagnostics(); let actual = response.into_model::()?; From 35c02bd6b7d497b9266687aa1eb95921d4ba7af0 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Fri, 11 Sep 2026 21:54:58 +0000 Subject: [PATCH 10/19] Applying formatting --- .../tests/e2e_test_cases/bootstrap_primary.rs | 36 +- .../tests/e2e_test_cases/capabilities.rs | 6 +- .../diagnostics_success_and_error.rs | 12 +- .../e2e_test_cases/item_create_conflict.rs | 35 +- .../tests/e2e_test_cases/item_lifecycle.rs | 457 +++++++----------- .../tests/e2e_test_cases/item_not_found.rs | 2 +- .../item_optimistic_concurrency.rs | 10 +- .../tests/e2e_test_cases/item_upsert.rs | 2 +- .../e2e_test_cases/query_invalid_syntax.rs | 2 +- .../query_parameterized_filter.rs | 18 +- .../eng/scripts/Invoke-CosmosTestSetup.ps1 | 20 +- 11 files changed, 226 insertions(+), 374 deletions(-) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs index b3c07fae0c7..1a018978b8a 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +use azure_core::http::StatusCode; + use crate::e2e_test_cases::{ - fixture::{E2eTestFixture, TestResult}, - support::{item, should_run, Item}, + fixture::{build_client, TestResult}, + support::{hosted_only, should_run}, }; #[tokio::test] @@ -12,23 +14,21 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn bootstrap_primary_endpoint() -> TestResult { - if !should_run("bootstrap.primary-success").await? { + if !should_run("bootstrap.primary-success")? { return Ok(()); } - E2eTestFixture::run(async |fixture| { - let expected = item("bootstrap-1", "A", 1); - fixture - .container - .create_item("A", &expected.id, &expected, None) - .await?; - let actual = fixture - .container - .read_item("A", &expected.id, None) - .await? - .into_model::()?; - assert_eq!(actual, expected); - Ok(()) - }) - .await + // Building the public SDK client against the reachable primary endpoint succeeds. + let client = build_client().await?; + assert!(hosted_only()); + + // The initialized client can complete its first account operation. + let database_id = format!("e2e-bootstrap-{}", azure_core::Uuid::new_v4()); + let response = client.create_database(&database_id, None).await?; + assert_eq!(response.status().status_code(), StatusCode::Created); + response.into_model()?; + + // Remove the resource created only to prove successful bootstrap. + client.database_client(&database_id).delete(None).await?; + Ok(()) } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs index e0783528374..4470bcd2fc4 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs @@ -11,15 +11,13 @@ use crate::e2e_test_cases::{fixture::TestResult, support::should_run}; ignore = "requires the externally hosted in-memory emulator" )] async fn capability_document_is_versioned() -> TestResult { - if !should_run("management.capabilities").await? { + if !should_run("management.capabilities")? { return Ok(()); } // Read the emulator's public management document without using Rust internals. let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; - let response = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build()? + let response = reqwest::Client::new() .get(url::Url::parse(&management_endpoint)?.join("capabilities")?) .send() .await?; diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs index f3c3498117d..6b5d99ae2f6 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs @@ -15,7 +15,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn diagnostics_cover_success_and_error() -> TestResult { - if !should_run("diagnostics.success-and-error").await? { + if !should_run("diagnostics.success-and-error")? { return Ok(()); } E2eTestFixture::run(async |fixture| { @@ -29,10 +29,10 @@ async fn diagnostics_cover_success_and_error() -> TestResult { let success = fixture.container.read_item("A", "item-1", None).await?; assert_eq!(success.status().status_code(), StatusCode::Ok); assert_critical_diagnostics(&success.diagnostics(), "read_item", StatusCode::Ok); - assert_eq!( - success.diagnostics().regions_contacted(), - vec![Region::EAST_US] - ); + assert!(success + .diagnostics() + .regions_contacted() + .contains(&Region::EAST_US)); // Error diagnostics preserve the same fields while reporting the terminal plain 404. let error = fixture @@ -53,7 +53,7 @@ async fn diagnostics_cover_success_and_error() -> TestResult { .diagnostics() .expect("service error must carry diagnostics"); assert_critical_diagnostics(&diagnostics, "read_item", StatusCode::NotFound); - assert_eq!(diagnostics.regions_contacted(), vec![Region::EAST_US]); + assert!(diagnostics.regions_contacted().contains(&Region::EAST_US)); Ok(()) }) .await diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs index f554aaa74bb..ba3ad9210e8 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs @@ -20,7 +20,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn duplicate_create_preserves_original() -> TestResult { - if !should_run("item.create-conflict").await? { + if !should_run("item.create-conflict")? { return Ok(()); } for case in duplicate_create_cases() { @@ -74,26 +74,24 @@ async fn duplicate_create_preserves_original() -> TestResult { StatusCode::Conflict, ); - // The failed duplicate create leaves the complete user document unchanged. - let mut stored: Value = fixture + // The failed duplicate create leaves every original field unchanged. + let stored: Value = fixture .container .read_item(case.partition_key.clone(), document_id, None) .await? .into_model()?; - let stored = stored - .as_object_mut() - .expect("stored document must be an object"); - for system_property in ["_rid", "_self", "_etag", "_attachments", "_ts"] { - stored.remove(system_property); + for (name, expected) in case + .original + .as_object() + .expect("original document must be an object") + { + assert_eq!( + stored.get(name), + Some(expected), + "fixture '{}' field '{name}' changed after duplicate create", + case.id + ); } - assert_eq!( - stored, - case.original - .as_object() - .expect("original document must be an object"), - "fixture '{}' changed after duplicate create", - case.id - ); Ok(()) }) .await?; @@ -119,7 +117,7 @@ fn duplicate_create_cases() -> Vec { .with_version(version), partition_key: PartitionKey::from("A"), original: json!({ "id": "duplicate-1", "pk": "A", "value": 1 }), - duplicate: json!({ "id": "duplicate-1", "pk": "A", "value": 2, "injected": true }), + duplicate: json!({ "id": "duplicate-1", "pk": "A", "value": 2 }), }; vec![ @@ -147,8 +145,7 @@ fn duplicate_create_cases() -> Vec { "id": "duplicate-1", "tenant": "tenant-a", "user": "user-1", - "value": 2, - "injected": true + "value": 2 }), }, ] diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index c9e79d1e7e1..cccea04d3c5 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -11,15 +11,13 @@ use azure_data_cosmos::{ }, RoutingStrategy, }; -use futures::FutureExt; use crate::e2e_test_cases::{ - catalog::{AccountDefinition, ClientDefinition, Profile, RuntimeDefinition}, - fixture::{build_client_with_defaults, ClientSetup, E2eTestFixture, TestResult}, - support::{ - assert_critical_diagnostics, item, selected_scenario_profile, write_options_with_content, - Item, + catalog::{ + selected_profile_for, AccountDefinition, ClientDefinition, Profile, RuntimeDefinition, }, + fixture::{build_client_with_defaults, E2eTestFixture, TestResult}, + support::{assert_critical_diagnostics, item, write_options_with_content, Item}, }; const REPLICATION_TIMEOUT: Duration = Duration::from_secs(5); @@ -36,8 +34,8 @@ const RETRY_DELAY: Duration = Duration::from_millis(50); not(any(test_category = "emulator_inmemory", test_category = "e2e")), ignore = "requires the externally hosted in-memory emulator" )] -async fn crud_lifecycle() -> TestResult { - let Some(profile) = selected_scenario_profile("item.lifecycle").await? else { +async fn item_lifecycle() -> TestResult { + let Some(profile) = selected_profile_for("item.lifecycle")? else { return Ok(()); }; let setup = SelectedLifecycleSetup::from_profile(&profile)?; @@ -57,128 +55,78 @@ async fn run_lifecycle_case( ) -> TestResult { let execution = setup.execution_name(read_case.name); let client = setup.build_client().await?; - let deterministic_delay = matches!( - read_case.expectation, - ReadExpectation::EventuallySucceeds { .. } - ); - if deterministic_delay { - set_replication_paused(true).await?; - } - let outcome = std::panic::AssertUnwindSafe(E2eTestFixture::run_with_client( - client, - "/pk".into(), - async |fixture| { - let item_id = format!("lifecycle-{}", execution.replace('/', "-")); - - // Create an item and capture the session token used by explicit Session reads. - let created = fixture - .container - .create_item("A", &item_id, item(&item_id, "A", 1), None) - .await?; - assert_eq!(created.status().status_code(), StatusCode::Created); - assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); - let create_session_token = created - .headers() - .session_token() - .map(|token| token.as_str().to_owned()); - - // Read the created item using this case's operation-level consistency behavior. - let read_outcome = read_created_item( - &fixture.container, - &item_id, - create_session_token, - read_case, - &execution, - ) + E2eTestFixture::run_with_client(client, "/pk".into(), async |fixture| { + let item_id = format!("lifecycle-{}", execution.replace('/', "-")); + + // Create an item and capture the session token used by explicit Session reads. + let created = fixture + .container + .create_item("A", &item_id, item(&item_id, "A", 1), None) .await?; - match read_outcome { - PostCreateReadOutcome::Item(actual) => assert_eq!( - actual, - item(&item_id, "A", 1), - "read returned the wrong item for '{execution}'" - ), - PostCreateReadOutcome::RejectedBeforeTransport => assert_eq!( - read_case.expectation, - ReadExpectation::RejectedBeforeTransport, - "read was rejected unexpectedly for '{execution}'" - ), - } + assert_eq!(created.status().status_code(), StatusCode::Created); + assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); + let create_session_token = created + .headers() + .session_token() + .map(|token| token.as_str().to_owned()); + + // Read the created item using this case's operation-level consistency behavior. + let read_outcome = read_created_item( + &fixture.container, + &item_id, + create_session_token, + read_case, + &execution, + ) + .await?; + match read_outcome { + PostCreateReadOutcome::Item(actual) => assert_eq!( + actual, + item(&item_id, "A", 1), + "read returned the wrong item for '{execution}'" + ), + PostCreateReadOutcome::RejectedBeforeTransport => assert_eq!( + read_case.expectation, + ReadExpectation::RejectedBeforeTransport, + "read was rejected unexpectedly for '{execution}'" + ), + } - // Replace the item and verify the returned model. - let replaced = fixture - .container - .replace_item( - "A", - &item_id, - item(&item_id, "A", 2), - Some(write_options_with_content()), - ) - .await?; - assert_eq!(replaced.status().status_code(), StatusCode::Ok); - assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); - assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); - - // Delete the item, then verify a plain 404/0 under this case's read strategy. - let deleted = fixture.container.delete_item("A", &item_id, None).await?; - assert_eq!(deleted.status().status_code(), StatusCode::NoContent); - assert_critical_diagnostics( - &deleted.diagnostics(), - "delete_item", - StatusCode::NoContent, - ); - let delete_session_token = deleted - .headers() - .session_token() - .map(|token| token.as_str().to_owned()) - .ok_or("delete response must carry a session token")?; - assert_item_deleted( - &fixture.container, + // Replace the item and verify the returned model. + let replaced = fixture + .container + .replace_item( + "A", &item_id, - delete_session_token, - read_case, - &execution, + item(&item_id, "A", 2), + Some(write_options_with_content()), ) .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); + assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); + + // Delete the item, then use the delete session token to verify a plain 404/0. + let deleted = fixture.container.delete_item("A", &item_id, None).await?; + assert_eq!(deleted.status().status_code(), StatusCode::NoContent); + assert_critical_diagnostics(&deleted.diagnostics(), "delete_item", StatusCode::NoContent); + let delete_session_token = deleted + .headers() + .session_token() + .map(|token| token.as_str().to_owned()) + .ok_or("delete response must carry a session token")?; + assert_item_deleted( + &fixture.container, + &item_id, + delete_session_token, + &execution, + ) + .await?; - Ok(()) - }, - )) - .catch_unwind() - .await; - let resume = if deterministic_delay { - set_replication_paused(false).await - } else { Ok(()) - }; - match outcome { - Ok(result) => { - result?; - resume - } - Err(panic) => { - if let Err(error) = resume { - eprintln!("resuming E2E replication after panic failed: {error}"); - } - std::panic::resume_unwind(panic) - } - } -} - -async fn set_replication_paused(paused: bool) -> TestResult { - let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; - let action = if paused { "pause" } else { "resume" }; - reqwest::Client::builder() - .timeout(REPLICATION_TIMEOUT) - .build()? - .post( - url::Url::parse(&management_endpoint)? - .join(&format!("regions/West%20US/replication/{action}"))?, - ) - .send() - .await? - .error_for_status()?; - Ok(()) + }) + .await } // Operation cases ------------------------------------------------------------- @@ -341,14 +289,9 @@ fn read_cases_for_default_precedence( } // Effective precedence is operation > client > runtime > account. These cases vary only the // operation value; the JSON profile supplies the selected client and runtime defaults. - let inherited_strategy = match client_default { - Some(strategy) => Some(parse_read_consistency(strategy)?), - None => runtime_default.map(parse_read_consistency).transpose()?, - }; - let inherited_uses_session = match inherited_strategy { - Some(ReadConsistencyStrategy::Default) | None => account.consistency == "session", - Some(ReadConsistencyStrategy::Session) => true, - Some(_) => false, + let inherited_uses_session = match client_default.or(runtime_default) { + Some(strategy) => parse_read_consistency(strategy)? == ReadConsistencyStrategy::Session, + None => account.consistency == "session", }; let inherited_expectation = if inherited_uses_session { eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) @@ -371,7 +314,7 @@ fn read_cases_for_default_precedence( PostCreateReadCase::new( "default_override_restores_account_consistency", Some(ReadConsistencyStrategy::Default), - SessionTokenBehavior::SdkManaged, + SessionTokenBehavior::ExplicitCreateResponse, eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), ), PostCreateReadCase::new( @@ -404,9 +347,25 @@ struct SelectedLifecycleSetup<'a> { impl<'a> SelectedLifecycleSetup<'a> { fn from_profile(profile: &'a Profile) -> TestResult { - let account = profile.selected_account()?; - let runtime = profile.selected_runtime()?; - let client = profile.selected_client()?; + let account_ids: Vec<_> = profile + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let runtime_ids: Vec<_> = profile + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + let client_ids: Vec<_> = profile + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + + let account = profile.account(selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &account_ids)?); + let runtime = profile.runtime(selected_axis("AZURE_COSMOS_E2E_RUNTIME", &runtime_ids)?); + let client = profile.client(selected_axis("AZURE_COSMOS_E2E_CLIENT", &client_ids)?); let read_region = lifecycle_read_region(profile)?; let routing = lifecycle_routing(client, &read_region)?; @@ -420,11 +379,18 @@ impl<'a> SelectedLifecycleSetup<'a> { } async fn build_client(&self) -> TestResult { - build_client_with_defaults(ClientSetup::from_profile( - self.runtime, - self.client, + build_client_with_defaults( self.routing.clone(), - )?) + parse_optional_read_consistency( + self.runtime.default_read_consistency_strategy.as_deref(), + )?, + parse_optional_read_consistency( + self.client.default_read_consistency_strategy.as_deref(), + )?, + parse_setup_switch(&self.runtime.gateway_v2, "backendDefault")?, + parse_setup_switch(&self.runtime.ppcb, "sdkDefault")?, + parse_setup_switch(&self.client.binary_encoding, "sdkDefault")?, + ) .await } @@ -518,17 +484,13 @@ async fn read_created_item( status_code: response.status().status_code(), substatus: response.status().sub_status().map(|value| value.value()), }; - record_request_statuses_or_outer( - &response.diagnostics(), - status, - &mut observed_statuses, - ); + record_request_statuses(&response.diagnostics(), &mut observed_statuses); + observed_statuses.push(status); if read_case .expectation .terminal_status() .matches(status.status_code, status.substatus) { - validate_observed_statuses(read_case, &observed_statuses, execution)?; assert_critical_diagnostics( &response.diagnostics(), "read_item", @@ -550,16 +512,14 @@ async fn read_created_item( substatus: error.status().sub_status().map(|value| value.value()), }; if let Some(diagnostics) = error.diagnostics() { - record_request_statuses_or_outer(&diagnostics, status, &mut observed_statuses); - } else { - observed_statuses.push(status); + record_request_statuses(&diagnostics, &mut observed_statuses); } + observed_statuses.push(status); if read_case .expectation .terminal_status() .matches(status.status_code, status.substatus) { - validate_observed_statuses(read_case, &observed_statuses, execution)?; if read_case.expectation == ReadExpectation::RejectedBeforeTransport { assert!( error.response().is_none(), @@ -583,91 +543,24 @@ async fn read_created_item( execution, &observed_statuses, )?; - if matches!( - read_case.expectation, - ReadExpectation::EventuallySucceeds { .. } - ) { - set_replication_paused(false).await?; - } } } tokio::time::sleep(RETRY_DELAY).await; } } -fn record_request_statuses_or_outer( - diagnostics: &azure_data_cosmos::diagnostics::DiagnosticsContext, - outer_status: ActualHttpStatus, - statuses: &mut Vec, -) { - let previous_len = statuses.len(); - record_request_statuses(diagnostics, statuses); - if statuses.len() == previous_len { - statuses.push(outer_status); - } -} - -fn validate_observed_statuses( - read_case: &PostCreateReadCase, - observed_statuses: &[ActualHttpStatus], - execution: &str, -) -> TestResult { - let terminal = read_case.expectation.terminal_status(); - let allowed = read_case.expectation.allowed_transient_statuses(); - if observed_statuses.iter().any(|actual| { - !terminal.matches(actual.status_code, actual.substatus) - && !allowed.iter().any(|expected| { - expected - .http_status() - .matches(actual.status_code, actual.substatus) - }) - }) { - return Err(format!( - "'{execution}' observed statuses outside its contract: {observed_statuses:?}" - ) - .into()); - } - if matches!( - read_case.expectation, - ReadExpectation::EventuallySucceeds { .. } - ) && !observed_statuses.iter().any(|actual| { - allowed.iter().any(|expected| { - expected - .http_status() - .matches(actual.status_code, actual.substatus) - }) - }) { - return Err(format!( - "'{execution}' did not observe the expected delayed-read transient; observed {observed_statuses:?}" - ) - .into()); - } - Ok(()) -} - async fn assert_item_deleted( container: &ContainerClient, item_id: &str, delete_session_token: String, - read_case: &PostCreateReadCase, execution: &str, ) -> TestResult { let mut operation = OperationOptions::default(); - operation.read_consistency_strategy = - if read_case.expectation == ReadExpectation::RejectedBeforeTransport { - // GlobalStrong cannot verify resource state on a non-Strong account. Its rejection was - // already asserted above, so use the strongest valid deletion check for this cell. - Some(ReadConsistencyStrategy::Session) - } else { - read_case.consistency_override - }; + operation.read_consistency_strategy = Some(ReadConsistencyStrategy::Session); operation.availability_strategy = Some(AvailabilityStrategy::Disabled); - let mut options = ItemReadOptions::default().with_operation_options(operation); - if read_case.session_token == SessionTokenBehavior::ExplicitCreateResponse - || read_case.expectation == ReadExpectation::RejectedBeforeTransport - { - options = options.with_session_token(delete_session_token); - } + let options = ItemReadOptions::default() + .with_operation_options(operation) + .with_session_token(delete_session_token); let deadline = tokio::time::Instant::now() + REPLICATION_TIMEOUT; loop { @@ -688,21 +581,12 @@ async fn assert_item_deleted( error.status().status_code(), error.status().sub_status().map(|value| value.value()), ) && tokio::time::Instant::now() < deadline => {} - Ok(_) - if matches!( - read_case.expectation, - ReadExpectation::EventuallySucceeds { .. } - ) && read_case.session_token == SessionTokenBehavior::Omitted - && tokio::time::Instant::now() < deadline => {} - Ok(response) => { - let diagnostics = response.diagnostics(); - let actual = response.into_model::()?; + Ok(_) if tokio::time::Instant::now() < deadline => {} + Ok(_) => { return Err(format!( - "deleted item for '{execution}' remained visible despite the delete session token: {actual:?}; regions: {:?}; requests: {:?}", - diagnostics.regions_contacted(), - diagnostics.requests() + "deleted item for '{execution}' remained visible after {REPLICATION_TIMEOUT:?}" ) - .into()); + .into()) } Err(error) => return Err(error.into()), } @@ -810,10 +694,42 @@ fn lifecycle_routing( } } +fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> TestResult<&'a str> { + match std::env::var(environment_variable) { + Ok(selected) => available + .iter() + .copied() + .find(|candidate| *candidate == selected) + .ok_or_else(|| { + format!("{environment_variable}='{selected}' is not one of {available:?}").into() + }), + Err(_) if available.len() == 1 => Ok(available[0]), + Err(_) => Err(format!( + "{environment_variable} is required because this profile defines {available:?}" + ) + .into()), + } +} + +fn parse_optional_read_consistency( + value: Option<&str>, +) -> TestResult> { + value.map(parse_read_consistency).transpose() +} + fn parse_read_consistency(value: &str) -> TestResult { value.parse::().map_err(Into::into) } +fn parse_setup_switch(value: &str, default: &str) -> TestResult> { + match value { + "enabled" => Ok(Some(true)), + "disabled" => Ok(Some(false)), + value if value == default => Ok(None), + value => Err(format!("unsupported setup switch '{value}'").into()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -936,53 +852,24 @@ mod tests { )) .expect("override profile must deserialize"); let account = profile.account("session"); - let inherited_expectations = [ - ( - "unset", - "unset", - SessionTokenBehavior::SdkManaged, - TransientReadStatus::SessionNotAvailable, - ), - ( - "eventual", - "unset", - SessionTokenBehavior::Omitted, - TransientReadStatus::PlainNotFound, - ), - ( - "session", - "unset", - SessionTokenBehavior::SdkManaged, - TransientReadStatus::SessionNotAvailable, - ), - ( - "unset", - "latestCommitted", - SessionTokenBehavior::Omitted, - TransientReadStatus::PlainNotFound, - ), - ( - "eventual", - "latestCommitted", - SessionTokenBehavior::Omitted, - TransientReadStatus::PlainNotFound, - ), - ( - "session", - "latestCommitted", - SessionTokenBehavior::Omitted, - TransientReadStatus::PlainNotFound, - ), - ]; for runtime in &profile.runtimes { for client in &profile.clients { - let (_, _, inherited_token, inherited_transient) = inherited_expectations - .iter() - .find(|(runtime_id, client_id, _, _)| { - *runtime_id == runtime.id && *client_id == client.id - }) - .expect("every override profile cell must have a hard-coded expectation"); + let inherited_uses_session = client + .default_read_consistency_strategy + .as_deref() + .or(runtime.default_read_consistency_strategy.as_deref()) + .is_none_or(|strategy| strategy == "Session"); + let inherited_expectation = if inherited_uses_session { + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) + } else { + eventually_succeeds_after(TransientReadStatus::PlainNotFound) + }; + let inherited_token = if inherited_uses_session { + SessionTokenBehavior::SdkManaged + } else { + SessionTokenBehavior::Omitted + }; let actual = read_cases_for_default_precedence( account, @@ -994,13 +881,13 @@ mod tests { PostCreateReadCase::new( "inherits_client_then_runtime_then_account_default", None, - *inherited_token, - eventually_succeeds_after(*inherited_transient), + inherited_token, + inherited_expectation, ), PostCreateReadCase::new( "default_override_restores_account_consistency", Some(ReadConsistencyStrategy::Default), - SessionTokenBehavior::SdkManaged, + SessionTokenBehavior::ExplicitCreateResponse, eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), ), PostCreateReadCase::new( @@ -1017,18 +904,6 @@ mod tests { ); } } - - let client_default = - read_cases_for_default_precedence(account, Some("Eventual"), Some("Default")) - .expect("client Default must be valid"); - assert_eq!( - client_default[0].session_token, - SessionTokenBehavior::SdkManaged - ); - assert_eq!( - client_default[0].expectation, - eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) - ); } #[test] diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs index 68b62f1fc2a..2e6e9f0599b 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs @@ -14,7 +14,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn not_found_does_not_cross_partition_keys() -> TestResult { - if !should_run("item.not-found-wrong-partition-key").await? { + if !should_run("item.not-found-wrong-partition-key")? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs index e44192bd485..75b5d57103e 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -use azure_core::http::StatusCode; +use azure_core::http::{Etag, StatusCode}; use azure_data_cosmos::options::{ItemWriteOptions, Precondition}; use crate::e2e_test_cases::{ @@ -15,7 +15,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn stale_etag_preserves_successful_update() -> TestResult { - if !should_run("item.optimistic-concurrency").await? { + if !should_run("item.optimistic-concurrency")? { return Ok(()); } E2eTestFixture::run(async |fixture| { @@ -24,7 +24,6 @@ async fn stale_etag_preserves_successful_update() -> TestResult { .container .create_item("A", "etag-1", item("etag-1", "A", 1), None) .await?; - assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); let initial_etag = created .headers() .etag() @@ -39,11 +38,10 @@ async fn stale_etag_preserves_successful_update() -> TestResult { .replace_item("A", "etag-1", item("etag-1", "A", 2), Some(current_options)) .await?; assert_eq!(replaced.status().status_code(), StatusCode::Ok); - assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); // Case 2: reusing the now-stale initial ETag cannot overwrite value 2. - let stale_options = - ItemWriteOptions::default().with_precondition(Precondition::IfMatch(initial_etag)); + let stale_options = ItemWriteOptions::default() + .with_precondition(Precondition::IfMatch(Etag::from(initial_etag.to_string()))); let error = fixture .container .replace_item("A", "etag-1", item("etag-1", "A", 3), Some(stale_options)) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs index faf6c7df6c0..9cea677333d 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs @@ -16,7 +16,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn upsert_creates_then_updates() -> TestResult { - if !should_run("item.upsert-create-update").await? { + if !should_run("item.upsert-create-update")? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs index 3d2ad862bd3..9c2af0ece4e 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs @@ -16,7 +16,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn invalid_query_is_not_an_empty_feed() -> TestResult { - if !should_run("query.invalid-syntax").await? { + if !should_run("query.invalid-syntax")? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs index bc68db05a4f..17c62ae40f8 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs @@ -15,7 +15,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn parameterized_query_filters_and_orders() -> TestResult { - if !should_run("query.parameterized-filter").await? { + if !should_run("query.parameterized-filter")? { return Ok(()); } E2eTestFixture::run(async |fixture| { @@ -26,11 +26,6 @@ async fn parameterized_query_filters_and_orders() -> TestResult { value.score = Some(score); fixture.container.create_item("A", &id, value, None).await?; } - let literal_id = "it's-2\" OR 1=1 --"; - fixture - .container - .create_item("A", literal_id, item(literal_id, "A", 0), None) - .await?; // Bind values as parameters and restrict execution to partition A. let query = Query::from( @@ -52,17 +47,6 @@ async fn parameterized_query_filters_and_orders() -> TestResult { .collect::>(), ["item-2", "item-3"] ); - - // Quotes and predicate syntax remain literal parameter data rather than query text. - let literal_query = - Query::from("SELECT * FROM c WHERE c.id = @id").with_parameter("@id", literal_id)?; - let literal_items: Vec = fixture - .container - .query_items::(literal_query, FeedScope::partition("A"), None) - .await? - .try_collect() - .await?; - assert_eq!(literal_items, [item(literal_id, "A", 0)]); Ok(()) }) .await diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 index 28f33dbe333..7e603254397 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 @@ -14,17 +14,17 @@ function Test-CosmosE2eScenarioDocuments { $profileSchema = ([System.IO.Path]::Combine($e2eTestRoot, 'schema', 'profile.v1.json')) $scenarioDocuments = @(Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'scenarios')) -Recurse -Filter '*.json' | ForEach-Object { - if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $scenarioSchema)) { - throw "Cosmos E2E scenario failed schema validation: $($_.FullName)" - } - Get-Content $_.FullName -Raw | ConvertFrom-Json - }) + if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $scenarioSchema)) { + throw "Cosmos E2E scenario failed schema validation: $($_.FullName)" + } + Get-Content $_.FullName -Raw | ConvertFrom-Json + }) $profileDocuments = @(Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'profiles')) -Filter '*.json' | ForEach-Object { - if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $profileSchema)) { - throw "Cosmos E2E profile failed schema validation: $($_.FullName)" - } - Get-Content $_.FullName -Raw | ConvertFrom-Json - }) + if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $profileSchema)) { + throw "Cosmos E2E profile failed schema validation: $($_.FullName)" + } + Get-Content $_.FullName -Raw | ConvertFrom-Json + }) $implementationPath = ([System.IO.Path]::Combine($e2eTestRoot, 'implementations', 'rust.json')) $implementation = Get-Content $implementationPath -Raw | ConvertFrom-Json From 2eeb5f10c005bb3be6050a811928c9021d9c3bad Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Fri, 11 Sep 2026 22:01:09 +0000 Subject: [PATCH 11/19] Add spec --- sdk/cosmos/docs/README.md | 1 + .../docs/specs/0028-functional-e2e-testing.md | 391 ++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 sdk/cosmos/docs/specs/0028-functional-e2e-testing.md diff --git a/sdk/cosmos/docs/README.md b/sdk/cosmos/docs/README.md index 6b50eef8b94..4a8bb19fe15 100644 --- a/sdk/cosmos/docs/README.md +++ b/sdk/cosmos/docs/README.md @@ -60,6 +60,7 @@ directly by Rust source remain with their crates. | 0025 | Throughput control (`specs/0025-throughput-control.md`) | | 0026 | Session consistency (`specs/0026-session-consistency.md`) | | 0027 | Hosted emulator (`specs/0027-hosted-emulator.md`) | +| 0028 | Functional E2E testing (`specs/0028-functional-e2e-testing.md`) | ## Architecture decision records diff --git a/sdk/cosmos/docs/specs/0028-functional-e2e-testing.md b/sdk/cosmos/docs/specs/0028-functional-e2e-testing.md new file mode 100644 index 00000000000..967ed5d0a22 --- /dev/null +++ b/sdk/cosmos/docs/specs/0028-functional-e2e-testing.md @@ -0,0 +1,391 @@ +# Cosmos SDK Functional E2E Testing + +**Status:** Draft +**Date:** 2026-09-11 +**Crates:** `azure_data_cosmos`, `azure_data_cosmos_driver`, +`azure_data_cosmos_emulator` + +## Purpose + +This plan introduces a dedicated functional end-to-end test suite for the Rust +Cosmos DB SDK. The suite defines and validates behavior observable through the +public `azure_data_cosmos` API while exercising the real driver, routing, +retry, session, diagnostics, and transport paths underneath it. + +The suite runs against three backends: + +- the hosted in-memory emulator through Gateway V1; +- the hosted in-memory emulator through Gateway V2; and +- Azure Cosmos DB live accounts where service fidelity is required. + +The hosted emulator provides deterministic setup, replication delay, fault, and +topology control. Live accounts remain the fidelity reference. Neither backend +replaces the other. + +The work is delivered in five sequentially reviewed and merged pull requests. +Development of later pull requests may proceed on dependent branches while an +earlier pull request is under review, but each pull request is rebased onto the +updated `main` and reviewed independently after its predecessor merges. + +## Goals + +- Validate Rust SDK functional behavior through public SDK APIs only. +- Exercise driver behavior implicitly through the supported Rust SDK surface. +- Cover positive and negative operation behavior, configuration precedence, + consistency, retries, fault injection, availability, topology transitions, + split/merge, partition migration, and diagnostics. +- Run deterministic behavior against the hosted emulator through Gateway V1 + and Gateway V2. +- Validate service fidelity through targeted and eventually comprehensive live + account execution. +- Keep scenario identities, requirements, setup profiles, backend + applicability, and implementation references machine-readable and + language-neutral. +- Keep executable behavior and assertions source-native, type checked, and easy + for humans to review. +- Allow Java, .NET, Python, and other SDKs to implement the same semantic + scenario IDs without prescribing language-specific APIs. +- Keep individual CI shards within a target of 15–20 minutes. + +## Non-goals + +- The JSON catalog is not a general-purpose test language or interpreter. +- Tests do not call the Rust driver directly to validate product behavior. +- The hosted emulator is not treated as proof of complete service fidelity. +- The suite does not duplicate every unit, component, or protocol conformance + test already present in the SDK or driver. +- The suite does not standardize incidental timing, opaque IDs, exact request + charge, error text, or serialized diagnostics. +- Cross-language reuse does not require identical source implementations. + +## Test architecture + +### Ownership boundary + +The suite deliberately separates reusable configuration data from executable +behavior. + +| Owner | Contents | +| --- | --- | +| Scenario JSON | Stable ID, title, requirement, maturity, tags, precedents, applicable setup profiles, backend applicability, fidelity, and required backend capabilities. | +| Profile JSON | Account topology and consistency, replication behavior, runtime configuration, and client configuration. | +| SDK implementation map | Scenario ID to source-native test implementation and implementation status. | +| Rust source | Fixtures, operation-level options, generated cases, sequencing, retries, concurrency, state validation, diagnostics, and assertions. | +| Pipeline matrices | Backend and setup-profile selection, scheduling, and sharding. | +| Hosted emulator management API | Deterministic external controls for emulator-only orchestration. | + +This boundary prevents the catalog from becoming a second programming language. +Operation-specific dimensions such as `ReadConsistencyStrategy`, patch +strategy, availability strategy, continuation behavior, fault rules, and batch +options remain beside the test that uses them. + +### Scenario catalog + +The language-neutral catalog lives under `sdk/cosmos/e2e_tests`: + +```text +e2e_tests/ +├── schema/ # Scenario and profile JSON Schemas +├── profiles/ # Reusable account/runtime/client setup +├── scenarios/ # Concise semantic scenario metadata +├── implementations/ # SDK-specific implementation maps +└── README.md +``` + +Scenario IDs are permanent. A semantic change creates a new ID; established IDs +are deprecated rather than repurposed. + +Each scenario declares backend applicability: + +- `required`: the scenario must execute and pass on the backend; +- `supported`: expected to work, but not yet enforced in every pipeline; +- `simulated`: deterministic emulator coverage without a full service-fidelity + claim; and +- `notApplicable`: intentionally unavailable, with a reason. + +An unavailable required capability is a configuration failure, not a silent +skip. + +### Setup profiles + +A profile describes account, runtime, and client axes. Pipelines select a +profile and, when an axis has multiple definitions, one definition from each +axis through: + +- `AZURE_COSMOS_E2E_PROFILE`; +- `AZURE_COSMOS_E2E_ACCOUNT`; +- `AZURE_COSMOS_E2E_RUNTIME`; and +- `AZURE_COSMOS_E2E_CLIENT`. + +For hosted-emulator jobs, setup translates the selected account definition into +an emulator configuration. Rust fixtures apply the selected runtime and client +configuration through public builders. + +For live jobs, a profile is matched to a fixed or provisioned account whose +actual consistency, regions, write mode, and feature configuration satisfy the +profile. Live setup must not silently pretend to apply an account property that +the selected account does not have. + +### Source-native implementations + +Rust implementations live under +`sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases`. Each scenario has its own +module. A module should be optimized for human review in this order: + +1. test entry point and selected profile expansion; +2. visible arrange, act, and assert lifecycle; +3. operation case definitions with descriptive behavioral names; +4. selected-profile translation; +5. retry or orchestration mechanics; and +6. focused unit tests for generated case matrices. + +Helpers should remove mechanical noise without hiding what behavior is tested +or what is asserted. + +### Public SDK boundary + +Product operations use only `azure_data_cosmos` public APIs. The driver is +exercised transitively and must not become the assertion surface for Rust SDK +functional contracts. + +Emulator-only orchestration may call the hosted emulator's external management +REST API. This keeps topology and fault control language-neutral without +exposing Rust internals to test implementations. + +## Assertion policy + +Tests assert stable, customer-observable behavior: + +- HTTP status and Cosmos substatus; +- typed error classification; +- returned models and item identity; +- resource state and operation side effects; +- ordering, uniqueness, continuation, and no-loss/no-duplication invariants; +- transaction atomicity; +- operation-level configuration precedence; +- terminal retry and failover behavior; and +- selected structured diagnostics such as operation, effective status, + attempts, and contacted regions. + +Transient service states are permitted rather than required when live timing is +nondeterministic. Tests accept only explicitly listed transient status/substatus +combinations and retry until a terminal state or a bounded deadline. Immediate +terminal success is always valid when replication has already converged. + +Exact first-attempt timing should be asserted only when a deterministic +emulator control makes it part of the test contract. + +## Execution tiers + +### Pull-request smoke + +- Small, stable functional subset. +- Hosted emulator Gateway V1 and Gateway V2. +- Fast enough to block normal pull requests. +- Includes catalog/schema validation and implementation-map consistency. + +### Scheduled hosted-emulator matrices + +- Expanded account/runtime/client profiles. +- Consistency and configuration-precedence matrices. +- Fault and topology scenarios requiring deterministic control. +- Split into shards with a target maximum duration of 15–20 minutes. + +### Live account validation + +- Targeted differential baselines initially. +- Comprehensive eligible-scenario coverage before final promotion. +- Fixed accounts where stable topology is required; provisioned resources where + isolation or specialized account configuration is required. +- Separate shards by account configuration and scenario cost. + +## Delivery plan + +### PR1 — Foundation and executable smoke + +**Status:** Implemented; under review. + +Scope: + +- scenario and profile JSON Schemas; +- concise language-neutral scenario metadata; +- reusable account/runtime/client profiles; +- Rust implementation map and catalog validation; +- source-native Rust E2E test target using public SDK APIs; +- one source module per scenario; +- hosted emulator capability discovery; +- hosted Gateway V1 and Gateway V2 CI execution; +- initial consistency and layered runtime/client/operation default matrices; +- scheduled expanded emulator profiles; and +- initial scenarios: + - client bootstrap; + - item create/read/replace/delete lifecycle; + - upsert create/update behavior; + - duplicate create conflict; + - missing ID and wrong partition key; + - optimistic concurrency with ETags; + - parameterized filtering and ordering; + - invalid query syntax; and + - critical success/error diagnostics. + +PR1 establishes architecture and representative execution. An `azureLive` +applicability value of `supported` records scenarios intended for live +validation but does not yet mean that all such scenarios are enforced by live +CI. + +### PR2 — Core operations and emulator fidelity + +Scope: + +- database and container control-plane lifecycle; +- item CRUD breadth across partition-key variants; +- Hash V1, Hash V2, and hierarchical partition keys; +- string, numeric, Boolean, null, and undefined partition-key behavior where + supported; +- item ID, partition-key, payload-size, and unique-key validation; +- query and read-feed pagination; +- feed ranges and continuation handling; +- change-feed modes and resume behavior; +- transactional batch success, failure, and atomicity; +- patch operation and strategy behavior; +- exact post-operation state assertions; +- expanded negative status/substatus coverage; +- emulator support needed for those scenarios; and +- selected live differential baselines used to confirm emulator fidelity. + +PR2 should add emulator behavior only when required by a concrete SDK scenario. +Differences discovered against live accounts must be fixed, explicitly modeled +as simulated, or documented as not applicable. + +### PR3 — Configuration, consistency, and resilience + +Scope: + +- primary and backup bootstrap paths; +- supported authentication modes; +- runtime, client, account, and operation configuration precedence; +- Gateway V1/Gateway V2 selection; +- binary encoding and text-response combinations; +- connection-pool and preferred-region behavior; +- all account consistency levels across applicable read operations; +- `ReadConsistencyStrategy` beyond the initial lifecycle matrix; +- session capture, explicit tokens, disabled management, and cross-page token + behavior; +- throttling, transport, service, and timeout retries; +- end-to-end operation deadlines; +- cross-region hedging; +- public fault-injection behavior; +- stable diagnostics for representative retry and failure paths; and +- representative OpenTelemetry metrics, spans, and logs. + +PR3 keeps fault predicates and operation-specific configuration in source code. +Only reusable environment setup belongs in profile JSON. + +### PR4 — Dynamic topology and availability + +Scope: + +- runtime region add, remove, offline, online, and recovery; +- write-region failover and failback; +- deterministic partition migration simulation; +- physical partition split and merge; +- manually controlled transition phases; +- operations issued before, during, and after each transition; +- stale routing and address-cache refresh; +- replication pause, resume, and convergence; +- per-partition automatic failover; +- per-partition circuit breaker behavior; +- hedging interaction with topology changes; +- query and change-feed continuation across topology changes; and +- no-loss, no-duplication, ordering, and bounded-recovery assertions. + +Dynamic controls are invoked through the hosted emulator management API. +Equivalent live scenarios are added only where Azure can expose the transition +safely and repeatably. + +### PR5 — Full matrix, live enforcement, and promotion + +Scope: + +- complete live-account setup profiles; +- fixed-account and provisioned-account E2E matrices; +- Azure Live execution for every eligible scenario; +- promotion of validated `azureLive` applicability from `supported` to + `required`; +- complete diagnostics and OpenTelemetry audit; +- generated pull-request and scheduled shard manifests; +- JUnit and scenario/profile/backend coverage reports; +- runtime measurement and shard calibration; +- 15–20 minute maximum shard target; +- retry and quarantine policy for environmental failures; +- documentation for local, hosted-emulator, and live execution; and +- cross-SDK portability review with Java, .NET, and Python implementations. + +PR5 is the release-readiness gate for the suite rather than a place to add +large amounts of previously untested functionality. + +## Pull-request workflow + +The five pull requests are reviewed and merged sequentially: + +1. merge the current pull request; +2. update local `main` from `origin/main`; +3. replay the next pull request's commits onto updated `main`; +4. verify its diff contains only that pull request's scope; +5. rerun its validation; and +6. open it for review. + +Development may proceed in parallel on dependent local branches. The repository +does not depend on GitHub stacked-PR automation, and reviewers see one +independent pull request at a time. + +## Quality gates + +Every pull request must: + +- validate all scenario and profile documents against their schemas; +- validate every scenario ID has exactly one Rust implementation-map entry; +- validate every active implementation maps to a discovered Rust test; +- validate pipeline matrices cover their declared profile axes; +- run `cargo fmt`; +- build affected crates; +- run Clippy with all features and targets; +- build Rust documentation without warnings; +- run relevant offline tests; +- run applicable hosted-emulator Gateway V1 and Gateway V2 tests; +- run required live tests for that phase; +- pass Markdown lint and spelling checks; and +- avoid silent skipping of required scenarios or capabilities. + +## Completion criteria + +The roadmap is complete when: + +- all planned functional domains have stable scenario IDs and active Rust + implementations; +- the pull-request smoke set is reliable and bounded; +- scheduled hosted-emulator matrices cover deterministic fault and topology + behavior through Gateway V1 and Gateway V2; +- every eligible scenario runs against appropriate live account profiles; +- required backend coverage is enforced rather than informational; +- CI reports scenario/profile/backend coverage; +- no shard exceeds the 15–20 minute target under normal conditions; and +- the catalog is usable by peer SDK teams without requiring them to adopt Rust + APIs or a shared behavioral interpreter. + +## Related documents + +- Hosted emulator: `specs/0027-hosted-emulator.md` +- In-memory emulator: `specs/0021-in-memory-emulator.md` +- Operation and transport pipelines: + `specs/0005-operation-and-transport-pipelines.md` +- Error codes and retries: `specs/0006-error-codes-and-retries.md` +- Partition-level failover: `specs/0008-partition-level-failover.md` +- Cross-region hedging: `specs/0009-cross-region-hedging.md` +- Gateway V2: `specs/0011-gateway-v2.md` +- Feed operations and dataflow: + `specs/0012-feed-operations-and-dataflow.md` +- Patch handler: `specs/0017-patch-handler.md` +- Diagnostics contract: `specs/0018-diagnostics-contract.md` +- Fault injection: `specs/0024-fault-injection.md` +- Session consistency: `specs/0026-session-consistency.md` From 64259e2238337c1bc7eee70de00d1f33ca34ab64 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Fri, 11 Sep 2026 23:09:37 +0000 Subject: [PATCH 12/19] Reacting to code review feedback --- .../tests/e2e_test_cases/bootstrap_primary.rs | 5 +-- .../tests/e2e_test_cases/capabilities.rs | 2 +- .../diagnostics_success_and_error.rs | 2 +- .../e2e_test_cases/item_create_conflict.rs | 2 +- .../tests/e2e_test_cases/item_lifecycle.rs | 43 +++++-------------- .../tests/e2e_test_cases/item_not_found.rs | 2 +- .../item_optimistic_concurrency.rs | 2 +- .../tests/e2e_test_cases/item_upsert.rs | 2 +- .../e2e_test_cases/query_invalid_syntax.rs | 2 +- .../query_parameterized_filter.rs | 2 +- .../src/driver/pipeline/operation_pipeline.rs | 11 ++++- .../docs/specs/0026-session-consistency.md | 12 ++++-- 12 files changed, 39 insertions(+), 48 deletions(-) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs index 1a018978b8a..e16ec6b2f7a 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs @@ -5,7 +5,7 @@ use azure_core::http::StatusCode; use crate::e2e_test_cases::{ fixture::{build_client, TestResult}, - support::{hosted_only, should_run}, + support::should_run, }; #[tokio::test] @@ -14,13 +14,12 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn bootstrap_primary_endpoint() -> TestResult { - if !should_run("bootstrap.primary-success")? { + if !should_run("bootstrap.primary-success").await? { return Ok(()); } // Building the public SDK client against the reachable primary endpoint succeeds. let client = build_client().await?; - assert!(hosted_only()); // The initialized client can complete its first account operation. let database_id = format!("e2e-bootstrap-{}", azure_core::Uuid::new_v4()); diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs index 4470bcd2fc4..d741b549da6 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs @@ -11,7 +11,7 @@ use crate::e2e_test_cases::{fixture::TestResult, support::should_run}; ignore = "requires the externally hosted in-memory emulator" )] async fn capability_document_is_versioned() -> TestResult { - if !should_run("management.capabilities")? { + if !should_run("management.capabilities").await? { return Ok(()); } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs index 6b5d99ae2f6..ec1e0c00dec 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs @@ -15,7 +15,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn diagnostics_cover_success_and_error() -> TestResult { - if !should_run("diagnostics.success-and-error")? { + if !should_run("diagnostics.success-and-error").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs index ba3ad9210e8..9674f496801 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs @@ -20,7 +20,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn duplicate_create_preserves_original() -> TestResult { - if !should_run("item.create-conflict")? { + if !should_run("item.create-conflict").await? { return Ok(()); } for case in duplicate_create_cases() { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index cccea04d3c5..bce3e6c0260 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -13,11 +13,12 @@ use azure_data_cosmos::{ }; use crate::e2e_test_cases::{ - catalog::{ - selected_profile_for, AccountDefinition, ClientDefinition, Profile, RuntimeDefinition, + catalog::{AccountDefinition, ClientDefinition, Profile, RuntimeDefinition}, + fixture::{build_client_with_defaults, ClientSetup, E2eTestFixture, TestResult}, + support::{ + assert_critical_diagnostics, item, selected_scenario_profile, write_options_with_content, + Item, }, - fixture::{build_client_with_defaults, E2eTestFixture, TestResult}, - support::{assert_critical_diagnostics, item, write_options_with_content, Item}, }; const REPLICATION_TIMEOUT: Duration = Duration::from_secs(5); @@ -34,8 +35,8 @@ const RETRY_DELAY: Duration = Duration::from_millis(50); not(any(test_category = "emulator_inmemory", test_category = "e2e")), ignore = "requires the externally hosted in-memory emulator" )] -async fn item_lifecycle() -> TestResult { - let Some(profile) = selected_profile_for("item.lifecycle")? else { +async fn crud_lifecycle() -> TestResult { + let Some(profile) = selected_scenario_profile("item.lifecycle").await? else { return Ok(()); }; let setup = SelectedLifecycleSetup::from_profile(&profile)?; @@ -379,18 +380,11 @@ impl<'a> SelectedLifecycleSetup<'a> { } async fn build_client(&self) -> TestResult { - build_client_with_defaults( + build_client_with_defaults(ClientSetup::from_profile( + self.runtime, + self.client, self.routing.clone(), - parse_optional_read_consistency( - self.runtime.default_read_consistency_strategy.as_deref(), - )?, - parse_optional_read_consistency( - self.client.default_read_consistency_strategy.as_deref(), - )?, - parse_setup_switch(&self.runtime.gateway_v2, "backendDefault")?, - parse_setup_switch(&self.runtime.ppcb, "sdkDefault")?, - parse_setup_switch(&self.client.binary_encoding, "sdkDefault")?, - ) + )?) .await } @@ -711,25 +705,10 @@ fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> TestR } } -fn parse_optional_read_consistency( - value: Option<&str>, -) -> TestResult> { - value.map(parse_read_consistency).transpose() -} - fn parse_read_consistency(value: &str) -> TestResult { value.parse::().map_err(Into::into) } -fn parse_setup_switch(value: &str, default: &str) -> TestResult> { - match value { - "enabled" => Ok(Some(true)), - "disabled" => Ok(Some(false)), - value if value == default => Ok(None), - value => Err(format!("unsupported setup switch '{value}'").into()), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs index 2e6e9f0599b..68b62f1fc2a 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs @@ -14,7 +14,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn not_found_does_not_cross_partition_keys() -> TestResult { - if !should_run("item.not-found-wrong-partition-key")? { + if !should_run("item.not-found-wrong-partition-key").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs index 75b5d57103e..f9ce2bb7953 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs @@ -15,7 +15,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn stale_etag_preserves_successful_update() -> TestResult { - if !should_run("item.optimistic-concurrency")? { + if !should_run("item.optimistic-concurrency").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs index 9cea677333d..faf6c7df6c0 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs @@ -16,7 +16,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn upsert_creates_then_updates() -> TestResult { - if !should_run("item.upsert-create-update")? { + if !should_run("item.upsert-create-update").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs index 9c2af0ece4e..3d2ad862bd3 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs @@ -16,7 +16,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn invalid_query_is_not_an_empty_feed() -> TestResult { - if !should_run("query.invalid-syntax")? { + if !should_run("query.invalid-syntax").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs index 17c62ae40f8..88f3d267877 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs @@ -15,7 +15,7 @@ use crate::e2e_test_cases::{ ignore = "requires the externally hosted in-memory emulator" )] async fn parameterized_query_filters_and_orders() -> TestResult { - if !should_run("query.parameterized-filter")? { + if !should_run("query.parameterized-filter").await? { return Ok(()); } E2eTestFixture::run(async |fixture| { diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs index 1ed2c08937a..3cae98101b7 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs @@ -4367,7 +4367,7 @@ fn session_consistency_strategy_for_operation( operation: &CosmosOperation, read_consistency_strategy: ReadConsistencyStrategy, ) -> ReadConsistencyStrategy { - if operation.is_read_only() { + if operation.is_read_only() || read_consistency_strategy == ReadConsistencyStrategy::Session { read_consistency_strategy } else { ReadConsistencyStrategy::Default @@ -4757,7 +4757,7 @@ mod tests { } #[test] - fn writes_ignore_read_consistency_strategy_for_session_capture() { + fn writes_ignore_non_session_read_consistency_strategy_for_session_capture() { let item = ItemReference::from_name(&test_container(), PartitionKey::from("pk1"), "doc1"); let write = CosmosOperation::create_item(item.clone()).with_body(b"{}".to_vec()); let read = CosmosOperation::read_item(item); @@ -4769,6 +4769,13 @@ mod tests { ), crate::options::ReadConsistencyStrategy::Default ); + assert_eq!( + super::session_consistency_strategy_for_operation( + &write, + crate::options::ReadConsistencyStrategy::Session, + ), + crate::options::ReadConsistencyStrategy::Session + ); assert_eq!( super::session_consistency_strategy_for_operation( &read, diff --git a/sdk/cosmos/docs/specs/0026-session-consistency.md b/sdk/cosmos/docs/specs/0026-session-consistency.md index 10deb233ee4..957aa27c630 100644 --- a/sdk/cosmos/docs/specs/0026-session-consistency.md +++ b/sdk/cosmos/docs/specs/0026-session-consistency.md @@ -201,17 +201,23 @@ knobs. The pipeline computes, per attempt: ```text +session_consistency_strategy = + read_consistency_strategy if operation is a read + Session if strategy is Session + Default otherwise + automatic_session_management_effective = partition_key_range_cache_enabled && !session_capturing_disabled - && read_consistency_strategy.is_session_effective(account_default) + && session_consistency_strategy.is_session_effective(account_default) ``` `is_session_effective` is true when the strategy is `Session`, or when the strategy is `Default` and the account default consistency level is `Session`. `Eventual`, `LatestCommitted`, and `GlobalStrong` deliberately leave the session -lane, so the pipeline neither resolves cached tokens nor captures response -tokens. +lane for reads. Writes ignore those read-only strategies and use the account +default, while an explicit `Session` strategy continues to resolve and capture +write tokens so a subsequent Session read can enforce read-your-writes. `session_capturing_disabled` is a single switch that turns off *both* automatic halves — no cache-based attach and no capture. Explicit per-operation tokens From db94adecefb2f2158318b33a1750140da293b812 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Sat, 12 Sep 2026 21:27:09 +0000 Subject: [PATCH 13/19] Revert eng/* changes to test execution script --- eng/scripts/Test-Packages.ps1 | 86 ++++---- .../tests/e2e_test_cases/item_lifecycle.rs | 199 +++++++++++++----- .../query_parameterized_filter.rs | 32 ++- .../tests/e2e_test_cases/support.rs | 14 +- .../in_memory_emulator_tests/session_token.rs | 69 +++++- .../src/driver/pipeline/operation_pipeline.rs | 79 +++++-- .../docs/specs/0026-session-consistency.md | 20 +- .../items/not-found-wrong-partition-key.json | 2 +- .../eng/scripts/Invoke-CosmosTestCleanup.ps1 | 19 +- .../eng/scripts/Invoke-CosmosTestSetup.ps1 | 29 +-- 10 files changed, 381 insertions(+), 168 deletions(-) diff --git a/eng/scripts/Test-Packages.ps1 b/eng/scripts/Test-Packages.ps1 index 7fc9912586e..6c516a5e970 100755 --- a/eng/scripts/Test-Packages.ps1 +++ b/eng/scripts/Test-Packages.ps1 @@ -51,7 +51,7 @@ function Invoke-CargoTest ( $message += " For more information see the pipeline Tests tab." } LogError $message - throw "$message Cargo exited with code $LASTEXITCODE." + exit $LASTEXITCODE } } @@ -100,55 +100,47 @@ foreach ($package in $packagesToTest) { foreach ($package in $packagesToTest) { $packageDirectory = ([System.IO.Path]::Combine($RepoRoot, $package.DirectoryPath)) - $cleanupScript = ([System.IO.Path]::Combine($packageDirectory, 'Test-Cleanup.ps1')) - try { - $setupScript = ([System.IO.Path]::Combine($packageDirectory, 'Test-Setup.ps1')) - if (Test-Path $setupScript) { - Write-Host "`n`nRunning test setup script for package: '$($package.Name)'`n" - Invoke-LoggedCommand $setupScript -GroupOutput -DoNotExitOnFailedExitCode - if ($LASTEXITCODE) { - throw "Test setup script failed for package '$($package.Name)' with exit code $LASTEXITCODE." - } + $setupScript = ([System.IO.Path]::Combine($packageDirectory, 'Test-Setup.ps1')) + if (Test-Path $setupScript) { + Write-Host "`n`nRunning test setup script for package: '$($package.Name)'`n" + Invoke-LoggedCommand $setupScript -GroupOutput + if (!$? -ne 0) { + LogError "Test setup script failed for package: '$($package.Name)'" + exit 1 } + } - Write-Host "`n`nTesting package: '$($package.Name)'`n" + Write-Host "`n`nTesting package: '$($package.Name)'`n" - $buildCommand = (@('cargo', 'build') + $cargoFeatureArgs + @('--keep-going')) -join ' ' - Invoke-LoggedCommand $buildCommand -GroupOutput -DoNotExitOnFailedExitCode - if ($LASTEXITCODE) { - throw "Build failed for package '$($package.Name)' with exit code $LASTEXITCODE." - } - Write-Host "`n`n" - - $manifestPath = [System.IO.Path]::Combine($packageDirectory, 'Cargo.toml') - $timestamp = Get-Date -Format "yyyyMMdd-HHmmss-fff" - - $docTestOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-doctest-$timestamp.json")) - Invoke-CargoTest ` - -TestParams "--doc" ` - -PackageName $package.Name ` - -ManifestPath $manifestPath ` - -OutputFile $docTestOutput - - $allTargetsOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-alltargets-$timestamp.json")) - Invoke-CargoTest ` - -TestParams "--lib --bins --tests --examples" ` - -PackageName $package.Name ` - -ManifestPath $manifestPath ` - -OutputFile $allTargetsOutput - - $benchCommand = (@('cargo', 'test', '--benches', '--manifest-path', $manifestPath) + $cargoFeatureArgs + @('--no-fail-fast')) -join ' ' - Invoke-LoggedCommand $benchCommand -GroupOutput -DoNotExitOnFailedExitCode - if ($LASTEXITCODE) { - throw "Benchmark tests failed for package '$($package.Name)' with exit code $LASTEXITCODE." - } - } - finally { - if (Test-Path $cleanupScript) { - Write-Host "`n`nRunning test cleanup script for package: '$($package.Name)'`n" - Invoke-LoggedCommand $cleanupScript -GroupOutput -DoNotExitOnFailedExitCode - # We ignore the exit code of the cleanup script. - } + $buildCommand = (@('cargo', 'build') + $cargoFeatureArgs + @('--keep-going')) -join ' ' + Invoke-LoggedCommand $buildCommand -GroupOutput + Write-Host "`n`n" + + $manifestPath = [System.IO.Path]::Combine($packageDirectory, 'Cargo.toml') + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss-fff" + + $docTestOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-doctest-$timestamp.json")) + Invoke-CargoTest ` + -TestParams "--doc" ` + -PackageName $package.Name ` + -ManifestPath $manifestPath ` + -OutputFile $docTestOutput + + $allTargetsOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-alltargets-$timestamp.json")) + Invoke-CargoTest ` + -TestParams "--lib --bins --tests --examples" ` + -PackageName $package.Name ` + -ManifestPath $manifestPath ` + -OutputFile $allTargetsOutput + + $benchCommand = (@('cargo', 'test', '--benches', '--manifest-path', $manifestPath) + $cargoFeatureArgs + @('--no-fail-fast')) -join ' ' + Invoke-LoggedCommand $benchCommand -GroupOutput + + $cleanupScript = ([System.IO.Path]::Combine($packageDirectory, 'Test-Cleanup.ps1')) + if (Test-Path $cleanupScript) { + Write-Host "`n`nRunning test cleanup script for package: '$($package.Name)'`n" + Invoke-LoggedCommand $cleanupScript -GroupOutput -DoNotExitOnFailedExitCode + # We ignore the exit code of the cleanup script. } } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index bce3e6c0260..4e2f8f6d220 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -79,6 +79,7 @@ async fn run_lifecycle_case( create_session_token, read_case, &execution, + &setup.read_region, ) .await?; match read_outcome { @@ -343,30 +344,15 @@ struct SelectedLifecycleSetup<'a> { account: &'a AccountDefinition, runtime: &'a RuntimeDefinition, client: &'a ClientDefinition, + read_region: Region, routing: RoutingStrategy, } impl<'a> SelectedLifecycleSetup<'a> { fn from_profile(profile: &'a Profile) -> TestResult { - let account_ids: Vec<_> = profile - .accounts - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let runtime_ids: Vec<_> = profile - .runtimes - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - let client_ids: Vec<_> = profile - .clients - .iter() - .map(|definition| definition.id.as_str()) - .collect(); - - let account = profile.account(selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &account_ids)?); - let runtime = profile.runtime(selected_axis("AZURE_COSMOS_E2E_RUNTIME", &runtime_ids)?); - let client = profile.client(selected_axis("AZURE_COSMOS_E2E_CLIENT", &client_ids)?); + let account = profile.selected_account()?; + let runtime = profile.selected_runtime()?; + let client = profile.selected_client()?; let read_region = lifecycle_read_region(profile)?; let routing = lifecycle_routing(client, &read_region)?; @@ -375,6 +361,7 @@ impl<'a> SelectedLifecycleSetup<'a> { account, runtime, client, + read_region, routing, }) } @@ -455,6 +442,7 @@ async fn read_created_item( create_session_token: Option, read_case: &PostCreateReadCase, execution: &str, + expected_region: &Region, ) -> TestResult { let mut operation = OperationOptions::default(); operation.read_consistency_strategy = read_case.consistency_override; @@ -480,6 +468,8 @@ async fn read_created_item( }; record_request_statuses(&response.diagnostics(), &mut observed_statuses); observed_statuses.push(status); + verify_observed_statuses(read_case, execution, &observed_statuses)?; + assert_initial_read_region(&response.diagnostics(), expected_region, execution); if read_case .expectation .terminal_status() @@ -507,8 +497,12 @@ async fn read_created_item( }; if let Some(diagnostics) = error.diagnostics() { record_request_statuses(&diagnostics, &mut observed_statuses); + if diagnostics.request_count() > 0 { + assert_initial_read_region(&diagnostics, expected_region, execution); + } } observed_statuses.push(status); + verify_observed_statuses(read_case, execution, &observed_statuses)?; if read_case .expectation .terminal_status() @@ -558,36 +552,68 @@ async fn assert_item_deleted( let deadline = tokio::time::Instant::now() + REPLICATION_TIMEOUT; loop { - match container + let result = container .read_item("A", item_id, Some(options.clone())) - .await - { - Err(error) - if PLAIN_NOT_FOUND.matches( - error.status().status_code(), - error.status().sub_status().map(|value| value.value()), - ) => - { - return Ok(()); + .await; + let status = match &result { + Ok(response) => ActualHttpStatus { + status_code: response.status().status_code(), + substatus: response.status().sub_status().map(|value| value.value()), + }, + Err(error) => ActualHttpStatus { + status_code: error.status().status_code(), + substatus: error.status().sub_status().map(|value| value.value()), + }, + }; + match deleted_read_action(status, tokio::time::Instant::now() < deadline) { + DeletedReadAction::Deleted => return Ok(()), + DeletedReadAction::Retry => {} + DeletedReadAction::SessionViolation => { + return Err(format!( + "deleted item for '{execution}' was served despite its explicit Session token" + ) + .into()) } - Err(error) - if SESSION_NOT_AVAILABLE.matches( - error.status().status_code(), - error.status().sub_status().map(|value| value.value()), - ) && tokio::time::Instant::now() < deadline => {} - Ok(_) if tokio::time::Instant::now() < deadline => {} - Ok(_) => { + DeletedReadAction::TimedOut => { return Err(format!( - "deleted item for '{execution}' remained visible after {REPLICATION_TIMEOUT:?}" + "read for '{execution}' remained at 404/1002 after {REPLICATION_TIMEOUT:?}" ) .into()) } - Err(error) => return Err(error.into()), + DeletedReadAction::Unexpected => match result { + Err(error) => return Err(error.into()), + Ok(_) => unreachable!("successful reads are session violations"), + }, } tokio::time::sleep(RETRY_DELAY).await; } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DeletedReadAction { + Deleted, + Retry, + SessionViolation, + TimedOut, + Unexpected, +} + +fn deleted_read_action(status: ActualHttpStatus, before_deadline: bool) -> DeletedReadAction { + if PLAIN_NOT_FOUND.matches(status.status_code, status.substatus) { + DeletedReadAction::Deleted + } else if SESSION_NOT_AVAILABLE.matches(status.status_code, status.substatus) { + if before_deadline { + DeletedReadAction::Retry + } else { + DeletedReadAction::TimedOut + } + } else if READ_SUCCEEDED.matches(status.status_code, status.substatus) { + DeletedReadAction::SessionViolation + } else { + DeletedReadAction::Unexpected + } +} + impl ReadExpectation { const fn terminal_status(self) -> ExpectedHttpStatus { match self { @@ -643,6 +669,44 @@ fn verify_transient_status( Ok(()) } +fn verify_observed_statuses( + read_case: &PostCreateReadCase, + execution: &str, + observed_statuses: &[ActualHttpStatus], +) -> TestResult { + let terminal = read_case.expectation.terminal_status(); + let allowed = read_case.expectation.allowed_transient_statuses(); + if let Some(unexpected) = observed_statuses.iter().find(|actual| { + !terminal.matches(actual.status_code, actual.substatus) + && !allowed.iter().any(|expected| { + expected + .http_status() + .matches(actual.status_code, actual.substatus) + }) + }) { + return Err(format!( + "'{execution}' observed unexpected internal read status {unexpected:?}; expected transient {allowed:?} or terminal {terminal:?}; observed {observed_statuses:?}" + ) + .into()); + } + Ok(()) +} + +fn assert_initial_read_region( + diagnostics: &azure_data_cosmos::diagnostics::DiagnosticsContext, + expected_region: &Region, + execution: &str, +) { + assert_eq!( + diagnostics + .requests() + .first() + .and_then(|request| request.region()), + Some(expected_region), + "'{execution}' must begin its read in the selected region" + ); +} + fn record_request_statuses( diagnostics: &azure_data_cosmos::diagnostics::DiagnosticsContext, observed: &mut Vec, @@ -688,23 +752,6 @@ fn lifecycle_routing( } } -fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> TestResult<&'a str> { - match std::env::var(environment_variable) { - Ok(selected) => available - .iter() - .copied() - .find(|candidate| *candidate == selected) - .ok_or_else(|| { - format!("{environment_variable}='{selected}' is not one of {available:?}").into() - }), - Err(_) if available.len() == 1 => Ok(available[0]), - Err(_) => Err(format!( - "{environment_variable} is required because this profile defines {available:?}" - ) - .into()), - } -} - fn parse_read_consistency(value: &str) -> TestResult { value.parse::().map_err(Into::into) } @@ -893,4 +940,46 @@ mod tests { assert!(!PLAIN_NOT_FOUND.matches(StatusCode::NotFound, Some(1002))); assert!(SESSION_NOT_AVAILABLE.matches(StatusCode::NotFound, Some(1002))); } + + #[test] + fn successful_read_after_delete_is_a_session_violation() { + let success = ActualHttpStatus { + status_code: StatusCode::Ok, + substatus: None, + }; + assert_eq!( + deleted_read_action(success, true), + DeletedReadAction::SessionViolation + ); + } + + #[test] + fn internal_attempts_must_match_the_case_allowlist() { + let case = PostCreateReadCase::new( + "plain-not-found-only", + Some(ReadConsistencyStrategy::Eventual), + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), + ); + let allowed = [ + ActualHttpStatus { + status_code: StatusCode::NotFound, + substatus: Some(0), + }, + ActualHttpStatus { + status_code: StatusCode::Ok, + substatus: None, + }, + ]; + assert!(verify_observed_statuses(&case, "allowed", &allowed).is_ok()); + + let disallowed = [ + ActualHttpStatus { + status_code: StatusCode::NotFound, + substatus: Some(1002), + }, + allowed[1], + ]; + assert!(verify_observed_statuses(&case, "disallowed", &disallowed).is_err()); + } } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs index 88f3d267877..c4797293cdd 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs @@ -19,12 +19,11 @@ async fn parameterized_query_filters_and_orders() -> TestResult { return Ok(()); } E2eTestFixture::run(async |fixture| { - // Arrange three ordered scores in one logical partition. - for score in 1..=3 { - let id = format!("item-{score}"); - let mut value = item(&id, "A", score); + // Arrange IDs and scores in different orders so ORDER BY is observable. + for (id, score) in [("a", 3), ("b", 1), ("c", 2)] { + let mut value = item(id, "A", score); value.score = Some(score); - fixture.container.create_item("A", &id, value, None).await?; + fixture.container.create_item("A", id, value, None).await?; } // Bind values as parameters and restrict execution to partition A. @@ -39,13 +38,32 @@ async fn parameterized_query_filters_and_orders() -> TestResult { .await?; let items: Vec = results.by_ref().try_collect().await?; - // The filter excludes score 1 and ORDER BY preserves score 2 before score 3. + // The filter excludes score 1 and ordering differs from ID/insertion order. assert_eq!( items .iter() .map(|item| item.id.as_str()) .collect::>(), - ["item-2", "item-3"] + ["c", "a"] + ); + + let descending = Query::from( + "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score DESC", + ) + .with_parameter("@pk", "A")? + .with_parameter("@min", 2)?; + let descending_items: Vec = fixture + .container + .query_items::(descending, FeedScope::partition("A"), None) + .await? + .try_collect() + .await?; + assert_eq!( + descending_items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["a", "c"] ); Ok(()) }) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs index 2ef9e87ae58..01ac5157ed6 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs @@ -97,6 +97,13 @@ async fn enforce_required_capabilities(scenario_id: &str) -> TestResult { .await? .error_for_status()?; let capabilities: CapabilityDocument = serde_json::from_slice(&response.bytes().await?)?; + if capabilities.api_version != 1 { + return Err(format!( + "scenario '{scenario_id}' requires capabilities API version 1, got {}", + capabilities.api_version + ) + .into()); + } let backend = match std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR") .ok() .as_deref() @@ -116,13 +123,6 @@ async fn enforce_required_capabilities(scenario_id: &str) -> TestResult { if requirements.is_empty() { return Ok(()); } - if capabilities.api_version != 1 { - return Err(format!( - "scenario '{scenario_id}' requires capabilities API version 1, got {}", - capabilities.api_version - ) - .into()); - } for requirement in requirements { let available = match requirement { Capability::Capabilities => true, diff --git a/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs b/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs index 954a0755e00..7cde0cfe607 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs @@ -182,10 +182,18 @@ struct Harness { impl Harness { async fn setup() -> Self { - Self::setup_with_partition_key_range_cache(true).await + Self::setup_with_options(true, ConsistencyLevel::Session, None).await } async fn setup_with_partition_key_range_cache(enabled: bool) -> Self { + Self::setup_with_options(enabled, ConsistencyLevel::Session, None).await + } + + async fn setup_with_options( + partition_key_range_cache_enabled: bool, + account_consistency: ConsistencyLevel, + read_consistency_strategy: Option, + ) -> Self { let observer = RecordingObserver::new(); let config = VirtualAccountConfig::new(vec![VirtualRegion::new( @@ -193,7 +201,7 @@ impl Harness { Url::parse(EMULATOR_GATEWAY_URL).unwrap(), )]) .unwrap() - .with_consistency(ConsistencyLevel::Session); + .with_consistency(account_consistency); let emulator = Arc::new( InMemoryEmulatorHttpClient::new(config).with_request_observer(observer.clone()), @@ -219,14 +227,16 @@ impl Harness { Url::parse(EMULATOR_GATEWAY_URL).unwrap(), EMULATOR_KEY, ); - let driver = runtime - .create_driver( - DriverOptions::builder(account) - .with_partition_key_range_cache_enabled(enabled) + let mut driver_options = DriverOptions::builder(account) + .with_partition_key_range_cache_enabled(partition_key_range_cache_enabled); + if let Some(strategy) = read_consistency_strategy { + driver_options = driver_options.with_operation_options( + OperationOptionsBuilder::new() + .with_read_consistency_strategy(strategy) .build(), - ) - .await - .unwrap(); + ); + } + let driver = runtime.create_driver(driver_options.build()).await.unwrap(); let container = driver .resolve_container( @@ -297,6 +307,47 @@ impl Harness { } } +#[tokio::test] +async fn session_strategy_on_eventual_account_captures_write_token_for_read() { + let h = Harness::setup_with_options( + true, + ConsistencyLevel::Eventual, + Some(ReadConsistencyStrategy::Session), + ) + .await; + + h.observer.clear(); + let create_token = h + .create("pk1", "item-1", 1) + .await + .expect("create should return a session token"); + let create_writes: Vec = h + .observer + .snapshots() + .into_iter() + .filter(|snapshot| snapshot.is_item_request() && snapshot.method == Method::Post) + .collect(); + assert_eq!(create_writes.len(), 1); + assert_eq!( + create_writes[0].session_token, None, + "read consistency must not automatically attach cached tokens to writes" + ); + + h.observer.clear(); + h.driver + .execute_singleton_operation( + CosmosOperation::read_item(h.item_ref("pk1", "item-1")), + OperationOptionsBuilder::new().build(), + ) + .await + .expect("Session read should succeed"); + assert_eq!( + h.observer.single_item_read().session_token.as_deref(), + Some(create_token.as_str()), + "read must carry the token captured from the preceding write" + ); +} + #[tokio::test] async fn cache_disabled_preserves_explicit_tokens_without_automatic_session_management() { let h = Harness::setup_with_partition_key_range_cache(false).await; diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs index 3cae98101b7..c0120b77ea2 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs @@ -451,11 +451,16 @@ pub(crate) async fn execute_operation_pipeline( .unwrap_or(ReadConsistencyStrategy::Default); let effective_consistency = resolve_effective_consistency(read_consistency_strategy, account_default_consistency); - let session_consistency_strategy = - session_consistency_strategy_for_operation(operation, read_consistency_strategy); - let session_consistency_active = partition_key_range_cache_enabled + let session_token_resolution_strategy = + session_token_resolution_strategy_for_operation(operation, read_consistency_strategy); + let session_token_resolution_active = partition_key_range_cache_enabled && !session_capturing_disabled - && session_consistency_strategy.is_session_effective(account_default_consistency); + && session_token_resolution_strategy.is_session_effective(account_default_consistency); + let session_token_capture_strategy = + session_token_capture_strategy_for_operation(operation, read_consistency_strategy); + let session_token_capture_active = partition_key_range_cache_enabled + && !session_capturing_disabled + && session_token_capture_strategy.is_session_effective(account_default_consistency); // Rule 4 (RCS validation): GlobalStrong is // valid only on reads against accounts whose default consistency is Strong. @@ -587,13 +592,22 @@ pub(crate) async fn execute_operation_pipeline( attempt_read_consistency_strategy, account_default_consistency, ); - let attempt_session_consistency_strategy = session_consistency_strategy_for_operation( + let attempt_session_token_resolution_strategy = + session_token_resolution_strategy_for_operation( + operation, + attempt_read_consistency_strategy, + ); + let attempt_session_token_resolution_active = partition_key_range_cache_enabled + && !session_capturing_disabled + && attempt_session_token_resolution_strategy + .is_session_effective(account_default_consistency); + let attempt_session_token_capture_strategy = session_token_capture_strategy_for_operation( operation, attempt_read_consistency_strategy, ); - let attempt_session_consistency_active = partition_key_range_cache_enabled + let attempt_session_token_capture_active = partition_key_range_cache_enabled && !session_capturing_disabled - && attempt_session_consistency_strategy + && attempt_session_token_capture_strategy .is_session_effective(account_default_consistency); // Emit one structured debug record per attempt with the chosen @@ -684,7 +698,8 @@ pub(crate) async fn execute_operation_pipeline( effective_consistency, read_consistency_strategy, session_manager, - session_consistency_active, + session_token_resolution_active, + session_token_capture_active, options, throughput_control, deadline, @@ -795,7 +810,7 @@ pub(crate) async fn execute_operation_pipeline( } else { ReadConsistencyStrategy::Default }, - resolved_session_token: attempt_session_consistency_active + resolved_session_token: attempt_session_token_resolution_active .then(|| { // Scope the session token to the target partition-key-range // only for thin-client (Gateway 2.0) requests: the RNTBD @@ -898,7 +913,7 @@ pub(crate) async fn execute_operation_pipeline( // Abort, or a retry action. 409/412 map to Abort, and the Abort // variant does not carry headers — capturing after evaluation // would silently drop tokens from those responses. - if attempt_session_consistency_active { + if attempt_session_token_capture_active { if let Some(cosmos_headers) = result.cosmos_headers() { if should_capture_session_token_from_status( cosmos_headers.substatus.as_ref(), @@ -958,7 +973,7 @@ pub(crate) async fn execute_operation_pipeline( Box::pin(driver.pre_resolve_partition_key_range_id( operation, &overrides, - session_consistency_active, + session_token_resolution_active, operation_options, )) .await; @@ -1293,7 +1308,8 @@ pub(crate) async fn execute_operation_pipeline( effective_consistency, read_consistency_strategy, session_manager, - session_consistency_active, + session_token_resolution_active, + session_token_capture_active, options, throughput_control, deadline, @@ -2923,9 +2939,10 @@ struct AttemptContext<'a> { /// rationale as `effective_consistency`. read_consistency_strategy: ReadConsistencyStrategy, session_manager: &'a SessionManager, - /// Whether session consistency is in effect for this operation - /// (drives session-token resolve/capture inside the attempt). - session_consistency_active: bool, + /// Whether cached session-token resolution is active for this operation. + session_token_resolution_active: bool, + /// Whether the winning response's session token should be captured. + session_token_capture_active: bool, options: &'a OperationOptionsView<'a>, throughput_control: Option, /// End-to-end deadline (operation timeout) — passed through to each @@ -3301,7 +3318,7 @@ async fn perform_single_attempt( // Scope to the target range only for thin-client (Gateway 2.0); classic // gateway keeps the composite token (see main-loop rationale). let resolved_session_token = ctx - .session_consistency_active + .session_token_resolution_active .then(|| { let scoped_pk_range_id = if matches!(routing.transport_mode, TransportMode::GatewayV2) { ctx.partition_key_range_id.as_ref().map(|id| id.as_str()) @@ -3406,7 +3423,7 @@ async fn perform_single_attempt( /// whose response the caller never observes would leak stale state and /// violate read-your-writes against the winning region. fn capture_session_token_for_winner(ctx: &AttemptContext<'_>, result: &TransportResult) { - if !ctx.session_consistency_active { + if !ctx.session_token_capture_active { return; } if let Some(cosmos_headers) = result.cosmos_headers() { @@ -4363,7 +4380,18 @@ async fn execute_hedged( } } -fn session_consistency_strategy_for_operation( +fn session_token_resolution_strategy_for_operation( + operation: &CosmosOperation, + read_consistency_strategy: ReadConsistencyStrategy, +) -> ReadConsistencyStrategy { + if operation.is_read_only() { + read_consistency_strategy + } else { + ReadConsistencyStrategy::Default + } +} + +fn session_token_capture_strategy_for_operation( operation: &CosmosOperation, read_consistency_strategy: ReadConsistencyStrategy, ) -> ReadConsistencyStrategy { @@ -4757,27 +4785,34 @@ mod tests { } #[test] - fn writes_ignore_non_session_read_consistency_strategy_for_session_capture() { + fn writes_capture_explicit_session_without_resolving_by_read_strategy() { let item = ItemReference::from_name(&test_container(), PartitionKey::from("pk1"), "doc1"); let write = CosmosOperation::create_item(item.clone()).with_body(b"{}".to_vec()); let read = CosmosOperation::read_item(item); assert_eq!( - super::session_consistency_strategy_for_operation( + super::session_token_capture_strategy_for_operation( &write, crate::options::ReadConsistencyStrategy::LatestCommitted, ), crate::options::ReadConsistencyStrategy::Default ); assert_eq!( - super::session_consistency_strategy_for_operation( + super::session_token_capture_strategy_for_operation( &write, crate::options::ReadConsistencyStrategy::Session, ), crate::options::ReadConsistencyStrategy::Session ); assert_eq!( - super::session_consistency_strategy_for_operation( + super::session_token_resolution_strategy_for_operation( + &write, + crate::options::ReadConsistencyStrategy::Session, + ), + crate::options::ReadConsistencyStrategy::Default + ); + assert_eq!( + super::session_token_capture_strategy_for_operation( &read, crate::options::ReadConsistencyStrategy::LatestCommitted, ), diff --git a/sdk/cosmos/docs/specs/0026-session-consistency.md b/sdk/cosmos/docs/specs/0026-session-consistency.md index 957aa27c630..7860957d408 100644 --- a/sdk/cosmos/docs/specs/0026-session-consistency.md +++ b/sdk/cosmos/docs/specs/0026-session-consistency.md @@ -201,23 +201,33 @@ knobs. The pipeline computes, per attempt: ```text -session_consistency_strategy = +session_token_resolution_strategy = + read_consistency_strategy if operation is a read + Default otherwise + +session_token_capture_strategy = read_consistency_strategy if operation is a read Session if strategy is Session Default otherwise -automatic_session_management_effective = +automatic_session_token_resolution_effective = + partition_key_range_cache_enabled + && !session_capturing_disabled + && session_token_resolution_strategy.is_session_effective(account_default) + +automatic_session_token_capture_effective = partition_key_range_cache_enabled && !session_capturing_disabled - && session_consistency_strategy.is_session_effective(account_default) + && session_token_capture_strategy.is_session_effective(account_default) ``` `is_session_effective` is true when the strategy is `Session`, or when the strategy is `Default` and the account default consistency level is `Session`. `Eventual`, `LatestCommitted`, and `GlobalStrong` deliberately leave the session lane for reads. Writes ignore those read-only strategies and use the account -default, while an explicit `Session` strategy continues to resolve and capture -write tokens so a subsequent Session read can enforce read-your-writes. +default for automatic token resolution. An explicit `Session` strategy enables +write-response capture without automatically attaching a cached token to the +write, so a subsequent Session read can enforce read-your-writes. `session_capturing_disabled` is a single switch that turns off *both* automatic halves — no cache-based attach and no capture. Explicit per-operation tokens diff --git a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json index 730b1fa18a3..7dd411aaa0c 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json @@ -9,7 +9,7 @@ { "sdk": "rust", "path": "sdk/cosmos/azure_data_cosmos_driver/tests/in_memory_emulator_tests/error_cases.rs", - "test": "read_item_not_found" + "test": "read_nonexistent_404" } ], "profiles": [ diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 index ccdb24eb9d0..8750cd325d9 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 @@ -19,7 +19,23 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $hostProcess | Wait-Process -Timeout 10 -ErrorAction SilentlyContinue } if ($env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY) { - Remove-Item $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY -Recurse -Force -ErrorAction SilentlyContinue + $ownershipMarker = ([System.IO.Path]::Combine( + $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY, + '.azure-data-cosmos-emulator-run')) + $ownedRunId = if (Test-Path -LiteralPath $ownershipMarker) { + Get-Content -LiteralPath $ownershipMarker -Raw + } + if ($env:AZURE_COSMOS_INMEMORY_RUN_ID -and + $ownedRunId -eq $env:AZURE_COSMOS_INMEMORY_RUN_ID) { + Remove-Item ` + -LiteralPath $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY ` + -Recurse ` + -Force ` + -ErrorAction SilentlyContinue + } + else { + LogWarning "Refusing to delete unowned emulator run directory '$env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY'." + } } } @@ -102,6 +118,7 @@ $env:AZURE_COSMOS_INMEMORY_EMULATOR_PID = $null $env:AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT = $null $env:AZURE_COSMOS_INMEMORY_ACCOUNT_ENDPOINT = $null $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY = $null +$env:AZURE_COSMOS_INMEMORY_RUN_ID = $null # Remove any --cfg=test_category="..." flag added by Test-Setup.ps1 or COSMOS_RUSTFLAGS. # The next package's setup will re-add the correct flag from COSMOS_RUSTFLAGS # (or from AZURE_COSMOS_EMULATOR_FLAVOR=vnext when running the vnext stage). diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 index 7e603254397..f12bbe2e338 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. # cSpell:ignore noui noexplorer disableratelimiting enableaadauthentication partitioncount LASTEXITCODE TEAMPROJECTID +#Requires -Version 7.4 # Load common ES scripts . "$PSScriptRoot\..\..\..\..\eng\common\scripts\common.ps1" @@ -36,8 +37,9 @@ function Test-CosmosE2eScenarioDocuments { $profileIds = @($profileDocuments.id | Sort-Object -Unique) $referencedProfileIds = @($scenarioDocuments.profiles | Sort-Object -Unique) - if (Compare-Object $profileIds $referencedProfileIds) { - throw 'Cosmos E2E profile files and scenario profile references must contain identical profile IDs.' + $unknownProfileIds = @($referencedProfileIds | Where-Object { $_ -notin $profileIds }) + if ($unknownProfileIds.Count -gt 0) { + throw "Cosmos E2E scenarios reference unknown profile IDs: $($unknownProfileIds -join ', ')." } } @@ -165,16 +167,23 @@ if ($env:AZURE_COSMOS_E2E_PROFILE -and if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $repoRoot = (Resolve-Path ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', '..', '..'))).Path - $runDirectory = if ($env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY) { + $runDirectoryRoot = if ($env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY) { $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY } else { - ([System.IO.Path]::Combine( - [System.IO.Path]::GetTempPath(), - "azure-data-cosmos-emulator-$([System.Guid]::NewGuid().ToString('N'))")) + [System.IO.Path]::GetTempPath() } + $runId = [System.Guid]::NewGuid().ToString('N') + $runDirectory = ([System.IO.Path]::Combine( + $runDirectoryRoot, + "azure-data-cosmos-emulator-$runId")) New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null + Set-Content ` + -LiteralPath ([System.IO.Path]::Combine($runDirectory, '.azure-data-cosmos-emulator-run')) ` + -Value $runId ` + -NoNewline $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY = $runDirectory + $env:AZURE_COSMOS_INMEMORY_RUN_ID = $runId $configuration = if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -eq 'inmemory-v2') { [System.IO.Path]::Combine($repoRoot, 'sdk', 'cosmos', 'azure_data_cosmos_emulator', 'config', 'ci-gateway-v2.json') } @@ -192,14 +201,6 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $configuration = $e2eConfiguration.Path $expectedAccountId = $e2eConfiguration.AccountId } - else { - $e2eConfiguration = New-CosmosE2eEmulatorConfig ` - -ProfileId 'hostedEmulatorSmoke' ` - -GatewayV2Enabled $expectedGateway20 ` - -OutputDirectory $runDirectory - $configuration = $e2eConfiguration.Path - $expectedAccountId = $e2eConfiguration.AccountId - } $managementEndpoint = $env:AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT $accountEndpoint = $env:AZURE_COSMOS_INMEMORY_ACCOUNT_ENDPOINT if ($managementEndpoint -and $accountEndpoint) { From d11c39cdfb416309e7efaa3ad3f2a961583fbe25 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Sat, 12 Sep 2026 21:53:50 +0000 Subject: [PATCH 14/19] Addressing code review feedback --- .../tests/e2e_test_cases/catalog.rs | 61 ++++++++++++++++++- .../tests/e2e_test_cases/item_lifecycle.rs | 49 +++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs index 85f1b3e80a1..f98522cbc6b 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -use std::collections::{BTreeMap, BTreeSet}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::PathBuf, +}; use serde::Deserialize; use serde_json::Value; @@ -9,6 +13,7 @@ use serde_json::Value; const DEFAULT_PROFILE: &str = "hostedEmulatorSmoke"; const SCENARIO_SCHEMA_REFERENCE: &str = "../../schema/scenario.v1.json"; const PROFILE_SCHEMA_REFERENCE: &str = "../schema/profile.v1.json"; +const SCENARIO_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../e2e_tests/scenarios"); const BACKENDS: [&str; 3] = [ "azureLive", "hostedEmulatorGatewayV1", @@ -285,6 +290,44 @@ fn load_scenarios() -> Result, String> { .collect() } +fn scenario_ids_on_disk() -> Result, String> { + let mut directories = vec![PathBuf::from(SCENARIO_DIRECTORY)]; + let mut documents = Vec::new(); + while let Some(directory) = directories.pop() { + let entries = fs::read_dir(&directory) + .map_err(|error| format!("failed to read '{}': {error}", directory.display()))?; + for entry in entries { + let path = entry.map_err(|error| error.to_string())?.path(); + if path.is_dir() { + directories.push(path); + } else if path + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + documents.push(path); + } + } + } + documents.sort(); + + let mut ids = BTreeSet::new(); + for path in documents { + let json = fs::read_to_string(&path) + .map_err(|error| format!("failed to read '{}': {error}", path.display()))?; + let document: Value = serde_json::from_str(&json) + .map_err(|error| format!("invalid scenario '{}': {error}", path.display()))?; + let id = document + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| format!("scenario '{}' has no string id", path.display()))?; + if !ids.insert(id.to_owned()) { + return Err(format!("duplicate scenario id '{id}' on disk")); + } + } + Ok(ids) +} + fn load_profiles() -> Result, String> { PROFILES .iter() @@ -503,6 +546,22 @@ pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { } } } + let registered_scenario_ids: BTreeSet<_> = + scenario_ids.iter().map(|id| (*id).to_owned()).collect(); + let discovered_scenario_ids = scenario_ids_on_disk()?; + if registered_scenario_ids != discovered_scenario_ids { + let unregistered: Vec<_> = discovered_scenario_ids + .difference(®istered_scenario_ids) + .cloned() + .collect(); + let missing: Vec<_> = registered_scenario_ids + .difference(&discovered_scenario_ids) + .cloned() + .collect(); + return Err(format!( + "scenario inventory differs from e2e_tests/scenarios; unregistered: {unregistered:?}, missing: {missing:?}" + )); + } let referenced_profiles: BTreeSet<_> = scenarios .iter() .flat_map(|scenario| scenario.profiles.iter().map(String::as_str)) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index 4e2f8f6d220..4c494f87a40 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -475,6 +475,7 @@ async fn read_created_item( .terminal_status() .matches(status.status_code, status.substatus) { + verify_required_transient_observed(read_case, execution, &observed_statuses)?; assert_critical_diagnostics( &response.diagnostics(), "read_item", @@ -508,6 +509,7 @@ async fn read_created_item( .terminal_status() .matches(status.status_code, status.substatus) { + verify_required_transient_observed(read_case, execution, &observed_statuses)?; if read_case.expectation == ReadExpectation::RejectedBeforeTransport { assert!( error.response().is_none(), @@ -692,6 +694,29 @@ fn verify_observed_statuses( Ok(()) } +fn verify_required_transient_observed( + read_case: &PostCreateReadCase, + execution: &str, + observed_statuses: &[ActualHttpStatus], +) -> TestResult { + let allowed = read_case.expectation.allowed_transient_statuses(); + if !allowed.is_empty() + && !observed_statuses.iter().any(|actual| { + allowed.iter().any(|expected| { + expected + .http_status() + .matches(actual.status_code, actual.substatus) + }) + }) + { + return Err(format!( + "'{execution}' reached terminal status without observing required transient {allowed:?}; observed {observed_statuses:?}" + ) + .into()); + } + Ok(()) +} + fn assert_initial_read_region( diagnostics: &azure_data_cosmos::diagnostics::DiagnosticsContext, expected_region: &Region, @@ -982,4 +1007,28 @@ mod tests { ]; assert!(verify_observed_statuses(&case, "disallowed", &disallowed).is_err()); } + + #[test] + fn eventual_success_requires_an_expected_transient() { + let case = PostCreateReadCase::new( + "session-not-available-first", + Some(ReadConsistencyStrategy::Session), + SessionTokenBehavior::ExplicitCreateResponse, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), + ); + let success = ActualHttpStatus { + status_code: StatusCode::Ok, + substatus: None, + }; + assert!(verify_required_transient_observed(&case, "missing", &[success]).is_err()); + + let observed = [ + ActualHttpStatus { + status_code: StatusCode::NotFound, + substatus: Some(1002), + }, + success, + ]; + assert!(verify_required_transient_observed(&case, "observed", &observed).is_ok()); + } } From e849d657163dba00589e4fde9ab4a55a9acb9242 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Sun, 13 Sep 2026 03:14:35 +0000 Subject: [PATCH 15/19] Fix session token capturing being also gated on session consistency --- .../in_memory_emulator_tests/session_token.rs | 68 +++++++++++++++-- .../src/driver/pipeline/operation_pipeline.rs | 74 ++++++++----------- .../docs/specs/0026-session-consistency.md | 30 ++++---- 3 files changed, 107 insertions(+), 65 deletions(-) diff --git a/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs b/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs index 7cde0cfe607..84471e5bf7c 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs @@ -309,12 +309,7 @@ impl Harness { #[tokio::test] async fn session_strategy_on_eventual_account_captures_write_token_for_read() { - let h = Harness::setup_with_options( - true, - ConsistencyLevel::Eventual, - Some(ReadConsistencyStrategy::Session), - ) - .await; + let h = Harness::setup_with_options(true, ConsistencyLevel::Eventual, None).await; h.observer.clear(); let create_token = h @@ -337,7 +332,9 @@ async fn session_strategy_on_eventual_account_captures_write_token_for_read() { h.driver .execute_singleton_operation( CosmosOperation::read_item(h.item_ref("pk1", "item-1")), - OperationOptionsBuilder::new().build(), + OperationOptionsBuilder::new() + .with_read_consistency_strategy(ReadConsistencyStrategy::Session) + .build(), ) .await .expect("Session read should succeed"); @@ -348,6 +345,63 @@ async fn session_strategy_on_eventual_account_captures_write_token_for_read() { ); } +#[tokio::test] +async fn single_write_account_does_not_attach_cached_token_to_writes() { + let h = Harness::setup().await; + h.create("pk1", "item-1", 1) + .await + .expect("create should return a session token"); + + h.observer.clear(); + h.replace("pk1", "item-1", 2) + .await + .expect("replace should return a session token"); + let writes: Vec<_> = h + .observer + .snapshots() + .into_iter() + .filter(|snapshot| snapshot.is_item_request() && snapshot.method == Method::Put) + .collect(); + assert_eq!(writes.len(), 1, "expected exactly one replace request"); + assert_eq!( + writes[0].session_token, None, + "ordinary single-write operations must not attach cached session tokens" + ); +} + +#[tokio::test] +async fn session_capturing_disabled_prevents_write_response_capture() { + let h = Harness::setup().await; + let body = serde_json::to_vec(&TestItem { + id: "item-1".to_owned(), + pk: "pk1".to_owned(), + value: 1, + }) + .unwrap(); + h.driver + .execute_singleton_operation( + CosmosOperation::create_item(h.item_ref("pk1", "item-1")).with_body(body), + OperationOptionsBuilder::new() + .with_session_capturing_disabled(true) + .build(), + ) + .await + .expect("create_item should succeed"); + + h.observer.clear(); + h.driver + .execute_singleton_operation( + CosmosOperation::read_item(h.item_ref("pk1", "item-1")), + OperationOptionsBuilder::new().build(), + ) + .await + .expect("read_item should succeed"); + assert_eq!( + h.observer.single_item_read().session_token, None, + "a response captured with automatic session management disabled must not populate the cache" + ); +} + #[tokio::test] async fn cache_disabled_preserves_explicit_tokens_without_automatic_session_management() { let h = Harness::setup_with_partition_key_range_cache(false).await; diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs index 4781d8d5ddf..6ea80e5ac04 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs @@ -442,12 +442,13 @@ pub(crate) async fn execute_operation_pipeline( session_token_resolution_strategy_for_operation(operation, read_consistency_strategy); let session_token_resolution_active = partition_key_range_cache_enabled && !session_capturing_disabled + && operation_allows_automatic_session_token_resolution( + operation, + location_snapshot.account.multiple_write_locations_enabled, + ) && session_token_resolution_strategy.is_session_effective(account_default_consistency); - let session_token_capture_strategy = - session_token_capture_strategy_for_operation(operation, read_consistency_strategy); - let session_token_capture_active = partition_key_range_cache_enabled - && !session_capturing_disabled - && session_token_capture_strategy.is_session_effective(account_default_consistency); + let session_token_capture_active = + partition_key_range_cache_enabled && !session_capturing_disabled; // Rule 4 (RCS validation): GlobalStrong is // valid only on reads against accounts whose default consistency is Strong. @@ -586,16 +587,13 @@ pub(crate) async fn execute_operation_pipeline( ); let attempt_session_token_resolution_active = partition_key_range_cache_enabled && !session_capturing_disabled + && operation_allows_automatic_session_token_resolution( + operation, + location.account.multiple_write_locations_enabled, + ) && attempt_session_token_resolution_strategy .is_session_effective(account_default_consistency); - let attempt_session_token_capture_strategy = session_token_capture_strategy_for_operation( - operation, - attempt_read_consistency_strategy, - ); - let attempt_session_token_capture_active = partition_key_range_cache_enabled - && !session_capturing_disabled - && attempt_session_token_capture_strategy - .is_session_effective(account_default_consistency); + let attempt_session_token_capture_active = session_token_capture_active; // Emit one structured debug record per attempt with the chosen // routing decision. Tests and SREs filter on this to verify which @@ -4378,15 +4376,13 @@ fn session_token_resolution_strategy_for_operation( } } -fn session_token_capture_strategy_for_operation( +fn operation_allows_automatic_session_token_resolution( operation: &CosmosOperation, - read_consistency_strategy: ReadConsistencyStrategy, -) -> ReadConsistencyStrategy { - if operation.is_read_only() || read_consistency_strategy == ReadConsistencyStrategy::Session { - read_consistency_strategy - } else { - ReadConsistencyStrategy::Default - } + multiple_write_locations_enabled: bool, +) -> bool { + operation.is_read_only() + || operation.operation_type() == OperationType::Batch + || multiple_write_locations_enabled } fn global_strong_account_validation_error( @@ -4772,25 +4768,12 @@ mod tests { } #[test] - fn writes_capture_explicit_session_without_resolving_by_read_strategy() { + fn automatic_session_token_resolution_respects_operation_and_topology() { let item = ItemReference::from_name(&test_container(), PartitionKey::from("pk1"), "doc1"); let write = CosmosOperation::create_item(item.clone()).with_body(b"{}".to_vec()); let read = CosmosOperation::read_item(item); + let batch = CosmosOperation::batch(test_container(), PartitionKey::from("pk1")); - assert_eq!( - super::session_token_capture_strategy_for_operation( - &write, - crate::options::ReadConsistencyStrategy::LatestCommitted, - ), - crate::options::ReadConsistencyStrategy::Default - ); - assert_eq!( - super::session_token_capture_strategy_for_operation( - &write, - crate::options::ReadConsistencyStrategy::Session, - ), - crate::options::ReadConsistencyStrategy::Session - ); assert_eq!( super::session_token_resolution_strategy_for_operation( &write, @@ -4798,13 +4781,18 @@ mod tests { ), crate::options::ReadConsistencyStrategy::Default ); - assert_eq!( - super::session_token_capture_strategy_for_operation( - &read, - crate::options::ReadConsistencyStrategy::LatestCommitted, - ), - crate::options::ReadConsistencyStrategy::LatestCommitted - ); + assert!(super::operation_allows_automatic_session_token_resolution( + &read, false + )); + assert!(!super::operation_allows_automatic_session_token_resolution( + &write, false + )); + assert!(super::operation_allows_automatic_session_token_resolution( + &write, true + )); + assert!(super::operation_allows_automatic_session_token_resolution( + &batch, false + )); } #[test] diff --git a/sdk/cosmos/docs/specs/0026-session-consistency.md b/sdk/cosmos/docs/specs/0026-session-consistency.md index 7860957d408..4b4331a0ae0 100644 --- a/sdk/cosmos/docs/specs/0026-session-consistency.md +++ b/sdk/cosmos/docs/specs/0026-session-consistency.md @@ -205,29 +205,28 @@ session_token_resolution_strategy = read_consistency_strategy if operation is a read Default otherwise -session_token_capture_strategy = - read_consistency_strategy if operation is a read - Session if strategy is Session - Default otherwise - automatic_session_token_resolution_effective = partition_key_range_cache_enabled && !session_capturing_disabled + && (operation is a read + || operation is a batch + || account has multiple write locations) && session_token_resolution_strategy.is_session_effective(account_default) automatic_session_token_capture_effective = - partition_key_range_cache_enabled - && !session_capturing_disabled - && session_token_capture_strategy.is_session_effective(account_default) + partition_key_range_cache_enabled + && !session_capturing_disabled ``` `is_session_effective` is true when the strategy is `Session`, or when the strategy is `Default` and the account default consistency level is `Session`. `Eventual`, `LatestCommitted`, and `GlobalStrong` deliberately leave the session -lane for reads. Writes ignore those read-only strategies and use the account -default for automatic token resolution. An explicit `Session` strategy enables -write-response capture without automatically attaching a cached token to the -write, so a subsequent Session read can enforce read-your-writes. +lane for reads. Capture is independent of consistency so a later Session read +can use tokens returned by earlier operations on Strong, Bounded Staleness, +Consistent Prefix, or Eventual accounts. Ordinary single-write operations do not +automatically attach cached tokens. Batches and writes on multi-write accounts +attach them when the account default consistency is Session. Explicit +per-operation tokens remain authoritative on every topology. `session_capturing_disabled` is a single switch that turns off *both* automatic halves — no cache-based attach and no capture. Explicit per-operation tokens @@ -524,9 +523,10 @@ formatting of driver state. `driver/pipeline/operation_pipeline.rs` and `driver/pipeline/retry_evaluation.rs`. - **End-to-end (in-memory emulator)** — `azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs` observes the - outgoing `x-ms-session-token` header to prove capture-then-resolve, cache - advance across writes, caller-token precedence, and the negative controls - (Eventual consistency, capturing disabled, empty cache). + outgoing `x-ms-session-token` header to prove consistency-independent capture, + Session-read resolution, single-write omission, cache advance across writes, + caller-token precedence, and the negative controls (Eventual reads, capturing + disabled, empty cache). - **Cross-backend** — dual-backend tests compare response session tokens between the in-memory emulator and a real account, which is what keeps the emulator's modeled contract (§1.1) honest. From 2954d3ad8bae7773df684da38150eeafd5d642f6 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Sun, 13 Sep 2026 04:22:50 +0000 Subject: [PATCH 16/19] Fix test flakiness --- .../emulator_tests/driver_fault_injection.rs | 24 ++++++++++++------- .../tests/framework/test_client.rs | 19 ++++++++++++++- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs b/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs index b9a133b3a64..0578c04f0b0 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs @@ -1119,11 +1119,13 @@ pub async fn fault_injection_429_honors_configurable_throttle_retry_count( .build(), ); - // Pin the throttle-retry budget at the runtime layer of the option - // view. A generous cumulative-wait budget keeps the attempt count the - // sole limiter for these small retry counts. No end-to-end latency - // policy is set, so the transport request carries no deadline and the - // forced-final retry is immediate. + // Pin the throttle-retry budget on the ReadItem operation under test. + // Setup and account-metadata operations retain their normal retry + // budgets so transient service-side metadata throttling cannot mask the + // fault-injection assertion. A generous cumulative-wait budget keeps + // the read's attempt count as the sole limiter for these small retry + // counts. No end-to-end latency policy is set, so the transport request + // carries no deadline and the forced-final retry is immediate. let operation_options = OperationOptionsBuilder::new() .with_throttling_retry_options( ThrottlingRetryOptionsBuilder::new() @@ -1135,9 +1137,8 @@ pub async fn fault_injection_429_honors_configurable_throttle_retry_count( let rule_for_assert = Arc::clone(&rule); Box::pin( - DriverTestClient::run_with_unique_db_and_fault_injection_options( + DriverTestClient::run_with_unique_db_and_fault_injection( vec![rule], - operation_options, async move |context, database| { let container_name = context.unique_container_name(); let container = context @@ -1153,7 +1154,14 @@ pub async fn fault_injection_429_honors_configurable_throttle_retry_count( // The read always observes 429 and ultimately fails once // the throttle budget is exhausted. - let read_result = context.read_item(&container, "item1", "pk1").await; + let read_result = context + .read_item_with_options( + &container, + "item1", + "pk1", + operation_options, + ) + .await; assert!( read_result.is_err(), "read must fail once the throttle budget is exhausted \ diff --git a/sdk/cosmos/azure_data_cosmos_driver/tests/framework/test_client.rs b/sdk/cosmos/azure_data_cosmos_driver/tests/framework/test_client.rs index 491734cc711..cdd06b3d05a 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/tests/framework/test_client.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/tests/framework/test_client.rs @@ -1005,6 +1005,23 @@ impl DriverTestRunContext { container: &ContainerReference, item_id: &str, partition_key: impl Into, + ) -> Result> { + self.read_item_with_options( + container, + item_id, + partition_key, + OperationOptions::default(), + ) + .await + } + + /// Reads an item using the driver with operation-specific options. + pub async fn read_item_with_options( + &self, + container: &ContainerReference, + item_id: &str, + partition_key: impl Into, + options: OperationOptions, ) -> Result> { let driver = self .client @@ -1017,7 +1034,7 @@ impl DriverTestRunContext { let operation = CosmosOperation::read_item(item_ref); let result = driver - .execute_singleton_operation(operation, OperationOptions::default()) + .execute_singleton_operation(operation, options) .await?; Ok(result) From f25504df50e4363ab5998bb4fc64895c14a756d2 Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Sun, 13 Sep 2026 04:39:58 +0000 Subject: [PATCH 17/19] Fix test failures --- .../src/driver/pipeline/operation_pipeline.rs | 33 +++++---- .../emulator_tests/driver_fault_injection.rs | 73 +++++++++---------- .../eng/scripts/Invoke-CosmosTestCleanup.ps1 | 4 + .../eng/scripts/Invoke-CosmosTestSetup.ps1 | 14 +++- 4 files changed, 67 insertions(+), 57 deletions(-) diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs index 6ea80e5ac04..cd468716b9a 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs @@ -436,17 +436,19 @@ pub(crate) async fn execute_operation_pipeline( .read_consistency_strategy() .copied() .unwrap_or(ReadConsistencyStrategy::Default); - let effective_consistency = - resolve_effective_consistency(read_consistency_strategy, account_default_consistency); - let session_token_resolution_strategy = - session_token_resolution_strategy_for_operation(operation, read_consistency_strategy); + let operation_read_consistency_strategy = + read_consistency_strategy_for_operation(operation, read_consistency_strategy); + let effective_consistency = resolve_effective_consistency( + operation_read_consistency_strategy, + account_default_consistency, + ); let session_token_resolution_active = partition_key_range_cache_enabled && !session_capturing_disabled && operation_allows_automatic_session_token_resolution( operation, location_snapshot.account.multiple_write_locations_enabled, ) - && session_token_resolution_strategy.is_session_effective(account_default_consistency); + && operation_read_consistency_strategy.is_session_effective(account_default_consistency); let session_token_capture_active = partition_key_range_cache_enabled && !session_capturing_disabled; @@ -574,25 +576,19 @@ pub(crate) async fn execute_operation_pipeline( if operation.prefers_write_endpoints_for_read() && routing.routing_fallback.is_some() { ReadConsistencyStrategy::Default } else { - read_consistency_strategy + operation_read_consistency_strategy }; let attempt_effective_consistency = resolve_effective_consistency( attempt_read_consistency_strategy, account_default_consistency, ); - let attempt_session_token_resolution_strategy = - session_token_resolution_strategy_for_operation( - operation, - attempt_read_consistency_strategy, - ); let attempt_session_token_resolution_active = partition_key_range_cache_enabled && !session_capturing_disabled && operation_allows_automatic_session_token_resolution( operation, location.account.multiple_write_locations_enabled, ) - && attempt_session_token_resolution_strategy - .is_session_effective(account_default_consistency); + && attempt_read_consistency_strategy.is_session_effective(account_default_consistency); let attempt_session_token_capture_active = session_token_capture_active; // Emit one structured debug record per attempt with the chosen @@ -4365,7 +4361,7 @@ async fn execute_hedged( } } -fn session_token_resolution_strategy_for_operation( +fn read_consistency_strategy_for_operation( operation: &CosmosOperation, read_consistency_strategy: ReadConsistencyStrategy, ) -> ReadConsistencyStrategy { @@ -4775,12 +4771,19 @@ mod tests { let batch = CosmosOperation::batch(test_container(), PartitionKey::from("pk1")); assert_eq!( - super::session_token_resolution_strategy_for_operation( + super::read_consistency_strategy_for_operation( &write, crate::options::ReadConsistencyStrategy::Session, ), crate::options::ReadConsistencyStrategy::Default ); + assert_eq!( + super::read_consistency_strategy_for_operation( + &read, + crate::options::ReadConsistencyStrategy::Eventual, + ), + crate::options::ReadConsistencyStrategy::Eventual + ); assert!(super::operation_allows_automatic_session_token_resolution( &read, false )); diff --git a/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs b/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs index 0578c04f0b0..e5d8fda2b4d 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs @@ -1136,51 +1136,44 @@ pub async fn fault_injection_429_honors_configurable_throttle_retry_count( .build(); let rule_for_assert = Arc::clone(&rule); - Box::pin( - DriverTestClient::run_with_unique_db_and_fault_injection( - vec![rule], - async move |context, database| { - let container_name = context.unique_container_name(); - let container = context - .create_container(&database, &container_name, "/pk") - .await?; - - // Seed the item with a write. The fault rule targets only - // ReadItem, so the seeding write is unaffected. - let item_json = br#"{"id": "item1", "pk": "pk1", "value": "test"}"#; - context - .create_item(&container, "item1", "pk1", item_json) - .await?; - - // The read always observes 429 and ultimately fails once - // the throttle budget is exhausted. - let read_result = context - .read_item_with_options( - &container, - "item1", - "pk1", - operation_options, - ) - .await; - assert!( - read_result.is_err(), - "read must fail once the throttle budget is exhausted \ + Box::pin(DriverTestClient::run_with_unique_db_and_fault_injection( + vec![rule], + async move |context, database| { + let container_name = context.unique_container_name(); + let container = context + .create_container(&database, &container_name, "/pk") + .await?; + + // Seed the item with a write. The fault rule targets only + // ReadItem, so the seeding write is unaffected. + let item_json = br#"{"id": "item1", "pk": "pk1", "value": "test"}"#; + context + .create_item(&container, "item1", "pk1", item_json) + .await?; + + // The read always observes 429 and ultimately fails once + // the throttle budget is exhausted. + let read_result = context + .read_item_with_options(&container, "item1", "pk1", operation_options) + .await; + assert!( + read_result.is_err(), + "read must fail once the throttle budget is exhausted \ (max_throttle_retry_count={max_throttle_retry_count})", - ); + ); - assert_eq!( - rule_for_assert.hit_count(), - expected_hits, - "max_throttle_retry_count={max_throttle_retry_count} must yield \ + assert_eq!( + rule_for_assert.hit_count(), + expected_hits, + "max_throttle_retry_count={max_throttle_retry_count} must yield \ {expected_hits} ReadItem attempts on the wire, but the 429 fault \ rule fired {} time(s)", - rule_for_assert.hit_count(), - ); + rule_for_assert.hit_count(), + ); - Ok(()) - }, - ), - ) + Ok(()) + }, + )) .await?; } diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 index 8750cd325d9..1254a921001 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 @@ -112,6 +112,10 @@ if ($env:AZURE_COSMOS_CONNECTION_STRING -eq "emulator" -or $env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $env:AZURE_COSMOS_CONNECTION_STRING = $null } +if ($env:AZURE_COSMOS_E2E_PROFILE -and + $env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { + $env:AZURE_COSMOS_DEFAULT_CONSISTENCY = $null +} $env:AZURE_COSMOS_TEST_MODE = $null $env:AZURE_COSMOS_EMULATOR_HOST = $null $env:AZURE_COSMOS_INMEMORY_EMULATOR_PID = $null diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 index f12bbe2e338..b8739649606 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 @@ -108,9 +108,18 @@ function New-CosmosE2eEmulatorConfig { $mode = if ($GatewayV2Enabled) { 'v2' } else { 'v1' } $path = ([System.IO.Path]::Combine($OutputDirectory, "azure-cosmos-e2e-$ProfileId-$($accountDefinition.id)-$mode.json")) $configuration | ConvertTo-Json -Depth 10 | Set-Content $path + $defaultConsistency = switch ([string]$accountDefinition.consistency) { + 'strong' { 'Strong' } + 'boundedStaleness' { 'BoundedStaleness' } + 'session' { 'Session' } + 'consistentPrefix' { 'ConsistentPrefix' } + 'eventual' { 'Eventual' } + default { throw "Unsupported account consistency '$($accountDefinition.consistency)'." } + } return [pscustomobject]@{ - Path = $path - AccountId = $configuration.account.id + Path = $path + AccountId = $configuration.account.id + DefaultConsistency = $defaultConsistency } } @@ -200,6 +209,7 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { -OutputDirectory $runDirectory $configuration = $e2eConfiguration.Path $expectedAccountId = $e2eConfiguration.AccountId + $env:AZURE_COSMOS_DEFAULT_CONSISTENCY = $e2eConfiguration.DefaultConsistency } $managementEndpoint = $env:AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT $accountEndpoint = $env:AZURE_COSMOS_INMEMORY_ACCOUNT_ENDPOINT From 20161e7491921d4ca3eb0c228f9505f909b12ccb Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Tue, 15 Sep 2026 18:44:46 +0000 Subject: [PATCH 18/19] Addressing code review comments --- sdk/cosmos/azure_data_cosmos/Cargo.toml | 2 +- .../azure_data_cosmos/tests/e2e_test_cases/catalog.rs | 4 ++-- .../azure_data_cosmos/tests/e2e_test_cases/fixture.rs | 5 +++-- .../tests/e2e_test_cases/item_lifecycle.rs | 6 +++--- .../azure_data_cosmos/tests/e2e_test_cases/support.rs | 4 ++-- sdk/cosmos/e2e_tests/README.md | 2 +- .../profiles/{hostedEmulatorSmoke.json => smokeTests.json} | 4 ++-- .../e2e_tests/scenarios/bootstrap/primary-success.json | 2 +- .../e2e_tests/scenarios/diagnostics/success-and-error.json | 2 +- sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json | 2 +- sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json | 2 +- .../scenarios/items/not-found-wrong-partition-key.json | 2 +- .../e2e_tests/scenarios/items/optimistic-concurrency.json | 2 +- .../e2e_tests/scenarios/items/upsert-create-update.json | 2 +- sdk/cosmos/e2e_tests/scenarios/management/capabilities.json | 2 +- sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json | 2 +- .../e2e_tests/scenarios/queries/parameterized-filter.json | 2 +- 17 files changed, 24 insertions(+), 23 deletions(-) rename sdk/cosmos/e2e_tests/profiles/{hostedEmulatorSmoke.json => smokeTests.json} (95%) diff --git a/sdk/cosmos/azure_data_cosmos/Cargo.toml b/sdk/cosmos/azure_data_cosmos/Cargo.toml index 8b40ba7c7f5..1b1049503b9 100644 --- a/sdk/cosmos/azure_data_cosmos/Cargo.toml +++ b/sdk/cosmos/azure_data_cosmos/Cargo.toml @@ -75,7 +75,7 @@ tokio = { workspace = true, features = [ "test-util", ] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } -uuid = { workspace = true, features = ["v4", "fast-rng"] } +uuid = { workspace = true, features = ["v4", "v7", "fast-rng"] } [lints] workspace = true diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs index f98522cbc6b..701d8b4a38f 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs @@ -10,7 +10,7 @@ use std::{ use serde::Deserialize; use serde_json::Value; -const DEFAULT_PROFILE: &str = "hostedEmulatorSmoke"; +const DEFAULT_PROFILE: &str = "smokeTests"; const SCENARIO_SCHEMA_REFERENCE: &str = "../../schema/scenario.v1.json"; const PROFILE_SCHEMA_REFERENCE: &str = "../schema/profile.v1.json"; const SCENARIO_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../e2e_tests/scenarios"); @@ -34,7 +34,7 @@ const SCENARIOS: &[&str] = &[ ]; const PROFILES: &[&str] = &[ - include_str!("../../../e2e_tests/profiles/hostedEmulatorSmoke.json"), + include_str!("../../../e2e_tests/profiles/smokeTests.json"), include_str!("../../../e2e_tests/profiles/lifecycleConsistencyMatrix.json"), include_str!("../../../e2e_tests/profiles/readConsistencyOverrideMatrix.json"), ]; diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs index 1b2becbc993..f2f73e858ba 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs @@ -125,8 +125,9 @@ impl E2eTestFixture { } async fn new(client: CosmosClient, partition_key: PartitionKeyDefinition) -> TestResult { - let database_id = format!("e2e-{}", Uuid::new_v4()); - let container_id = format!("items-{}", Uuid::new_v4()); + // Preserve creation time in leaked resource IDs so cleanup tooling can age them out. + let database_id = format!("e2e-{}", Uuid::now_v7()); + let container_id = format!("items-{}", Uuid::now_v7()); client.create_database(&database_id, None).await?; let database = client.database_client(&database_id); let cleanup = DatabaseCleanup::new(client.clone(), database_id); diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index 4c494f87a40..77d03270cd0 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -27,7 +27,7 @@ const RETRY_DELAY: Duration = Duration::from_millis(50); // The JSON profile selects the account, runtime, and client configuration. Rust then expands // that setup into the operation-level cases below: // -// * hostedEmulatorSmoke: one account-default read; +// * smokeTests: one account-default read; // * lifecycleConsistencyMatrix: Default, Eventual, Session, LatestCommitted, and GlobalStrong; // * readConsistencyOverrideMatrix: inherit defaults, restore account default, and Eventual. #[tokio::test] @@ -198,7 +198,7 @@ fn read_cases_for_selected_profile( setup: &SelectedLifecycleSetup<'_>, ) -> TestResult> { match setup.profile.id.as_str() { - "hostedEmulatorSmoke" => Ok(vec![PostCreateReadCase::new( + "smokeTests" => Ok(vec![PostCreateReadCase::new( "default_strategy_reads_created_item", Some(ReadConsistencyStrategy::Default), SessionTokenBehavior::SdkManaged, @@ -751,7 +751,7 @@ fn record_request_statuses( fn lifecycle_read_region(profile: &Profile) -> TestResult { match profile.id.as_str() { - "hostedEmulatorSmoke" => Ok(Region::EAST_US), + "smokeTests" => Ok(Region::EAST_US), "lifecycleConsistencyMatrix" | "readConsistencyOverrideMatrix" => Ok(Region::WEST_US), profile => { Err(format!("item.lifecycle does not define a read region for '{profile}'").into()) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs index 01ac5157ed6..621c88fefdc 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs @@ -69,8 +69,8 @@ pub(super) async fn should_run(scenario_id: &str) -> TestResult { pub(super) async fn selected_scenario_profile(scenario_id: &str) -> TestResult> { init_test_tracing(); let Some(profile) = selected_profile_for(scenario_id)? else { - let selected = std::env::var("AZURE_COSMOS_E2E_PROFILE") - .unwrap_or_else(|_| "hostedEmulatorSmoke".to_owned()); + let selected = + std::env::var("AZURE_COSMOS_E2E_PROFILE").unwrap_or_else(|_| "smokeTests".to_owned()); eprintln!("SKIP {scenario_id}: profile '{selected}' does not select it"); return Ok(None); }; diff --git a/sdk/cosmos/e2e_tests/README.md b/sdk/cosmos/e2e_tests/README.md index ef3d16ca5f7..f80a33a190f 100644 --- a/sdk/cosmos/e2e_tests/README.md +++ b/sdk/cosmos/e2e_tests/README.md @@ -46,7 +46,7 @@ selected profile. The item lifecycle implementation uses three profiles: -- `hostedEmulatorSmoke` for the default PR smoke case; +- `smokeTests` for the default PR smoke case on any supported backend; - `lifecycleConsistencyMatrix` for five account consistency configurations; - `readConsistencyOverrideMatrix` for runtime and client default precedence. diff --git a/sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json b/sdk/cosmos/e2e_tests/profiles/smokeTests.json similarity index 95% rename from sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json rename to sdk/cosmos/e2e_tests/profiles/smokeTests.json index 26a517b5078..75185408d04 100644 --- a/sdk/cosmos/e2e_tests/profiles/hostedEmulatorSmoke.json +++ b/sdk/cosmos/e2e_tests/profiles/smokeTests.json @@ -1,7 +1,7 @@ { "$schema": "../schema/profile.v1.json", "specVersion": "1.0", - "id": "hostedEmulatorSmoke", + "id": "smokeTests", "accounts": [ { "id": "sessionSingleRegion", @@ -33,4 +33,4 @@ "routing": "proximity" } ] -} +} \ No newline at end of file diff --git a/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json index ac3a06b1ac5..759bfc07bcf 100644 --- a/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json +++ b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke" + "smokeTests" ], "tags": [ "prSmoke", diff --git a/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json index 07faa111ead..75dacb96707 100644 --- a/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json +++ b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke" + "smokeTests" ], "tags": [ "prSmoke", diff --git a/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json index b6a1867f55e..dd7ec0a286a 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke" + "smokeTests" ], "tags": [ "prSmoke", diff --git a/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json index b678beaef89..c8ae7535280 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke", + "smokeTests", "lifecycleConsistencyMatrix", "readConsistencyOverrideMatrix" ], diff --git a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json index 7dd411aaa0c..fa927903cd6 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke" + "smokeTests" ], "tags": [ "prSmoke", diff --git a/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json index 7659ff765ca..e07bfc3a5b4 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke" + "smokeTests" ], "tags": [ "prSmoke", diff --git a/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json index 8037eb27473..04c2ea557ed 100644 --- a/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json +++ b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke" + "smokeTests" ], "tags": [ "prSmoke", diff --git a/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json index 6bc3f0d0152..db52a58d13e 100644 --- a/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json +++ b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke" + "smokeTests" ], "tags": [ "prSmoke", diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json index a65734f274d..a35375c96a3 100644 --- a/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json +++ b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke" + "smokeTests" ], "tags": [ "prSmoke", diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json index 836b8224d06..977b4b2bcdb 100644 --- a/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json +++ b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json @@ -13,7 +13,7 @@ } ], "profiles": [ - "hostedEmulatorSmoke" + "smokeTests" ], "tags": [ "prSmoke", From eb872b4da2f3a99870954a3c2538f18e6550631b Mon Sep 17 00:00:00 2001 From: Fabian Meiswinkel Date: Thu, 17 Sep 2026 04:12:30 +0000 Subject: [PATCH 19/19] Addressed code review feedback --- .../diagnostics_success_and_error.rs | 77 +- .../tests/e2e_test_cases/fixture.rs | 47 +- .../e2e_test_cases/item_create_conflict.rs | 120 ++-- .../tests/e2e_test_cases/item_lifecycle.rs | 661 ++++++++---------- .../tests/e2e_test_cases/item_not_found.rs | 55 +- .../item_optimistic_concurrency.rs | 113 +-- .../tests/e2e_test_cases/item_upsert.rs | 87 +-- .../e2e_test_cases/query_invalid_syntax.rs | 41 +- .../query_parameterized_filter.rs | 97 +-- 9 files changed, 617 insertions(+), 681 deletions(-) diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs index ec1e0c00dec..654ee22f4d8 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs @@ -5,7 +5,7 @@ use azure_core::http::StatusCode; use azure_data_cosmos::options::Region; use crate::e2e_test_cases::{ - fixture::{E2eTestFixture, TestResult}, + fixture::{E2eTest, TestResult}, support::{assert_critical_diagnostics, item, should_run}, }; @@ -18,43 +18,44 @@ async fn diagnostics_cover_success_and_error() -> TestResult { if !should_run("diagnostics.success-and-error").await? { return Ok(()); } - E2eTestFixture::run(async |fixture| { - // Arrange one readable item in East US. - fixture - .container - .create_item("A", "item-1", item("item-1", "A", 1), None) - .await?; + E2eTest::builder() + .run(async |fixture| { + // Arrange one readable item in East US. + fixture + .container + .create_item("A", "item-1", item("item-1", "A", 1), None) + .await?; - // Success diagnostics identify the operation, 200 status, activity, request, and region. - let success = fixture.container.read_item("A", "item-1", None).await?; - assert_eq!(success.status().status_code(), StatusCode::Ok); - assert_critical_diagnostics(&success.diagnostics(), "read_item", StatusCode::Ok); - assert!(success - .diagnostics() - .regions_contacted() - .contains(&Region::EAST_US)); + // Success diagnostics identify the operation, 200 status, activity, request, and region. + let success = fixture.container.read_item("A", "item-1", None).await?; + assert_eq!(success.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&success.diagnostics(), "read_item", StatusCode::Ok); + assert!(success + .diagnostics() + .regions_contacted() + .contains(&Region::EAST_US)); - // Error diagnostics preserve the same fields while reporting the terminal plain 404. - let error = fixture - .container - .read_item("A", "missing", None) - .await - .expect_err("missing read must fail"); - assert_eq!(error.status().status_code(), StatusCode::NotFound); - assert_eq!( - error - .status() - .sub_status() - .map(|value| value.value()) - .unwrap_or(0), - 0 - ); - let diagnostics = error - .diagnostics() - .expect("service error must carry diagnostics"); - assert_critical_diagnostics(&diagnostics, "read_item", StatusCode::NotFound); - assert!(diagnostics.regions_contacted().contains(&Region::EAST_US)); - Ok(()) - }) - .await + // Error diagnostics preserve the same fields while reporting the terminal plain 404. + let error = fixture + .container + .read_item("A", "missing", None) + .await + .expect_err("missing read must fail"); + assert_eq!(error.status().status_code(), StatusCode::NotFound); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + let diagnostics = error + .diagnostics() + .expect("service error must carry diagnostics"); + assert_critical_diagnostics(&diagnostics, "read_item", StatusCode::NotFound); + assert!(diagnostics.regions_contacted().contains(&Region::EAST_US)); + Ok(()) + }) + .await } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs index f2f73e858ba..0fbf0a2eac8 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs @@ -24,6 +24,13 @@ pub struct E2eTestFixture { pub container: ContainerClient, } +pub struct E2eTest; + +pub struct E2eTestBuilder { + client: Option, + partition_key: PartitionKeyDefinition, +} + pub struct ClientSetup { pub routing_strategy: RoutingStrategy, pub runtime_read_consistency: Option, @@ -76,26 +83,40 @@ struct DatabaseCleanup { database_id: String, } -impl E2eTestFixture { - pub async fn run(test: F) -> TestResult - where - F: AsyncFnOnce(&E2eTestFixture) -> TestResult, - { - Self::run_with_partition_key("/pk".into(), test).await +impl E2eTest { + pub fn builder() -> E2eTestBuilder { + E2eTestBuilder { + client: None, + partition_key: "/pk".into(), + } + } +} + +impl E2eTestBuilder { + pub fn with_client(mut self, client: CosmosClient) -> Self { + self.client = Some(client); + self } - pub async fn run_with_partition_key( - partition_key: PartitionKeyDefinition, - test: F, - ) -> TestResult + pub fn with_partition_key_definition(mut self, partition_key: PartitionKeyDefinition) -> Self { + self.partition_key = partition_key; + self + } + + pub async fn run(self, test: F) -> TestResult where F: AsyncFnOnce(&E2eTestFixture) -> TestResult, { - let client = build_client().await?; - Self::run_with_client(client, partition_key, test).await + let client = match self.client { + Some(client) => client, + None => build_client().await?, + }; + E2eTestFixture::run(client, self.partition_key, test).await } +} - pub async fn run_with_client( +impl E2eTestFixture { + async fn run( client: CosmosClient, partition_key: PartitionKeyDefinition, test: F, diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs index 9674f496801..62914d712b8 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs @@ -9,7 +9,7 @@ use azure_data_cosmos::{ use serde_json::{json, Value}; use crate::e2e_test_cases::{ - fixture::{E2eTestFixture, TestResult}, + fixture::{E2eTest, TestResult}, support::{assert_critical_diagnostics, should_run}, }; @@ -34,67 +34,69 @@ async fn duplicate_create_preserves_original() -> TestResult { Some(document_id) ); - E2eTestFixture::run_with_partition_key(case.partition_key_definition, async |fixture| { - // Arrange the original value 1 document. - fixture - .container - .create_item( - case.partition_key.clone(), - document_id, - &case.original, - None, - ) - .await?; + E2eTest::builder() + .with_partition_key_definition(case.partition_key_definition) + .run(async |fixture| { + // Arrange the original value 1 document. + fixture + .container + .create_item( + case.partition_key.clone(), + document_id, + &case.original, + None, + ) + .await?; - // Creating value 2 with the same ID and partition key returns conflict. - let error = fixture - .container - .create_item( - case.partition_key.clone(), - document_id, - &case.duplicate, - None, - ) - .await - .expect_err("duplicate create must fail"); - assert_eq!(error.status().status_code(), StatusCode::Conflict); - assert_eq!( - error - .status() - .sub_status() - .map(|value| value.value()) - .unwrap_or(0), - 0 - ); - assert_critical_diagnostics( - &error - .diagnostics() - .expect("service error must carry diagnostics"), - "create_item", - StatusCode::Conflict, - ); - - // The failed duplicate create leaves every original field unchanged. - let stored: Value = fixture - .container - .read_item(case.partition_key.clone(), document_id, None) - .await? - .into_model()?; - for (name, expected) in case - .original - .as_object() - .expect("original document must be an object") - { + // Creating value 2 with the same ID and partition key returns conflict. + let error = fixture + .container + .create_item( + case.partition_key.clone(), + document_id, + &case.duplicate, + None, + ) + .await + .expect_err("duplicate create must fail"); + assert_eq!(error.status().status_code(), StatusCode::Conflict); assert_eq!( - stored.get(name), - Some(expected), - "fixture '{}' field '{name}' changed after duplicate create", - case.id + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + assert_critical_diagnostics( + &error + .diagnostics() + .expect("service error must carry diagnostics"), + "create_item", + StatusCode::Conflict, ); - } - Ok(()) - }) - .await?; + + // The failed duplicate create leaves every original field unchanged. + let stored: Value = fixture + .container + .read_item(case.partition_key.clone(), document_id, None) + .await? + .into_model()?; + for (name, expected) in case + .original + .as_object() + .expect("original document must be an object") + { + assert_eq!( + stored.get(name), + Some(expected), + "fixture '{}' field '{name}' changed after duplicate create", + case.id + ); + } + Ok(()) + }) + .await?; } Ok(()) } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs index 77d03270cd0..284a259d65f 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -14,7 +14,7 @@ use azure_data_cosmos::{ use crate::e2e_test_cases::{ catalog::{AccountDefinition, ClientDefinition, Profile, RuntimeDefinition}, - fixture::{build_client_with_defaults, ClientSetup, E2eTestFixture, TestResult}, + fixture::{build_client_with_defaults, ClientSetup, E2eTest, TestResult}, support::{ assert_critical_diagnostics, item, selected_scenario_profile, write_options_with_content, Item, @@ -40,12 +40,7 @@ async fn crud_lifecycle() -> TestResult { return Ok(()); }; let setup = SelectedLifecycleSetup::from_profile(&profile)?; - let read_cases = read_cases_for_selected_profile(&setup)?; - - for read_case in read_cases { - run_lifecycle_case(&setup, &read_case).await?; - } - Ok(()) + run_selected_lifecycle_cases(&setup).await } // This is the lifecycle contract. Keep the operation sequence and its assertions visible here; @@ -57,80 +52,298 @@ async fn run_lifecycle_case( let execution = setup.execution_name(read_case.name); let client = setup.build_client().await?; - E2eTestFixture::run_with_client(client, "/pk".into(), async |fixture| { - let item_id = format!("lifecycle-{}", execution.replace('/', "-")); - - // Create an item and capture the session token used by explicit Session reads. - let created = fixture - .container - .create_item("A", &item_id, item(&item_id, "A", 1), None) + E2eTest::builder() + .with_client(client) + .run(async |fixture| { + let item_id = format!("lifecycle-{}", execution.replace('/', "-")); + + // Create an item and capture the session token used by explicit Session reads. + let created = fixture + .container + .create_item("A", &item_id, item(&item_id, "A", 1), None) + .await?; + assert_eq!(created.status().status_code(), StatusCode::Created); + assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); + let create_session_token = created + .headers() + .session_token() + .map(|token| token.as_str().to_owned()); + + // Read the created item using this case's operation-level consistency behavior. + let read_outcome = read_created_item( + &fixture.container, + &item_id, + create_session_token, + read_case, + &execution, + &setup.read_region, + ) .await?; - assert_eq!(created.status().status_code(), StatusCode::Created); - assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); - let create_session_token = created - .headers() - .session_token() - .map(|token| token.as_str().to_owned()); - - // Read the created item using this case's operation-level consistency behavior. - let read_outcome = read_created_item( - &fixture.container, - &item_id, - create_session_token, - read_case, - &execution, - &setup.read_region, - ) - .await?; - match read_outcome { - PostCreateReadOutcome::Item(actual) => assert_eq!( - actual, - item(&item_id, "A", 1), - "read returned the wrong item for '{execution}'" - ), - PostCreateReadOutcome::RejectedBeforeTransport => assert_eq!( - read_case.expectation, - ReadExpectation::RejectedBeforeTransport, - "read was rejected unexpectedly for '{execution}'" - ), - } + match read_outcome { + PostCreateReadOutcome::Item(actual) => assert_eq!( + actual, + item(&item_id, "A", 1), + "read returned the wrong item for '{execution}'" + ), + PostCreateReadOutcome::RejectedBeforeTransport => assert_eq!( + read_case.expectation, + ReadExpectation::RejectedBeforeTransport, + "read was rejected unexpectedly for '{execution}'" + ), + } - // Replace the item and verify the returned model. - let replaced = fixture - .container - .replace_item( - "A", + // Replace the item and verify the returned model. + let replaced = fixture + .container + .replace_item( + "A", + &item_id, + item(&item_id, "A", 2), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); + assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); + + // Delete the item, then use the delete session token to verify a plain 404/0. + let deleted = fixture.container.delete_item("A", &item_id, None).await?; + assert_eq!(deleted.status().status_code(), StatusCode::NoContent); + assert_critical_diagnostics( + &deleted.diagnostics(), + "delete_item", + StatusCode::NoContent, + ); + let delete_session_token = deleted + .headers() + .session_token() + .map(|token| token.as_str().to_owned()) + .ok_or("delete response must carry a session token")?; + assert_item_deleted( + &fixture.container, &item_id, - item(&item_id, "A", 2), - Some(write_options_with_content()), + delete_session_token, + &execution, ) .await?; - assert_eq!(replaced.status().status_code(), StatusCode::Ok); - assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); - assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); - - // Delete the item, then use the delete session token to verify a plain 404/0. - let deleted = fixture.container.delete_item("A", &item_id, None).await?; - assert_eq!(deleted.status().status_code(), StatusCode::NoContent); - assert_critical_diagnostics(&deleted.diagnostics(), "delete_item", StatusCode::NoContent); - let delete_session_token = deleted - .headers() - .session_token() - .map(|token| token.as_str().to_owned()) - .ok_or("delete response must carry a session token")?; - assert_item_deleted( - &fixture.container, - &item_id, - delete_session_token, - &execution, + + Ok(()) + }) + .await +} + +async fn run_selected_lifecycle_cases(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + match setup.profile.id.as_str() { + "smokeTests" => default_smoke_read(setup).await, + "lifecycleConsistencyMatrix" => { + default_strategy_uses_account_consistency(setup).await?; + eventual_strategy_allows_replication_lag(setup).await?; + session_strategy_uses_create_token(setup).await?; + latest_committed_is_region_local(setup).await?; + global_strong_requires_strong_account(setup).await + } + "readConsistencyOverrideMatrix" => { + inherited_defaults_follow_precedence(setup).await?; + default_override_restores_account_consistency(setup).await?; + eventual_override_wins_over_defaults(setup).await + } + profile => Err(format!("item.lifecycle does not implement profile '{profile}'").into()), + } +} + +async fn default_smoke_read(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "default_strategy_reads_created_item", + Some(ReadConsistencyStrategy::Default), + SessionTokenBehavior::SdkManaged, + ReadExpectation::SucceedsImmediately, + ), + ) + .await +} + +async fn default_strategy_uses_account_consistency( + setup: &SelectedLifecycleSetup<'_>, +) -> TestResult { + let (session_token, expectation) = match setup.account.consistency.as_str() { + "strong" => ( + SessionTokenBehavior::Omitted, + ReadExpectation::SucceedsImmediately, + ), + "session" => ( + SessionTokenBehavior::SdkManaged, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), + ), + _ => ( + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), + ), + }; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "default_strategy_uses_account_consistency", + Some(ReadConsistencyStrategy::Default), + session_token, + expectation, + ), + ) + .await +} + +async fn eventual_strategy_allows_replication_lag( + setup: &SelectedLifecycleSetup<'_>, +) -> TestResult { + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "eventual_strategy_allows_replication_lag", + Some(ReadConsistencyStrategy::Eventual), + SessionTokenBehavior::Omitted, + regional_read_expectation(setup.account), + ), + ) + .await +} + +async fn session_strategy_uses_create_token(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "session_strategy_uses_create_token", + Some(ReadConsistencyStrategy::Session), + SessionTokenBehavior::ExplicitCreateResponse, + session_read_expectation(setup.account), + ), + ) + .await +} + +async fn latest_committed_is_region_local(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "latest_committed_is_region_local", + Some(ReadConsistencyStrategy::LatestCommitted), + SessionTokenBehavior::Omitted, + regional_read_expectation(setup.account), + ), + ) + .await +} + +async fn global_strong_requires_strong_account(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + let expectation = if setup.account.consistency == "strong" { + ReadExpectation::SucceedsImmediately + } else { + ReadExpectation::RejectedBeforeTransport + }; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "global_strong_requires_strong_account", + Some(ReadConsistencyStrategy::GlobalStrong), + SessionTokenBehavior::Omitted, + expectation, + ), + ) + .await +} + +async fn inherited_defaults_follow_precedence(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + require_session_account(setup.account)?; + let inherited_strategy = setup + .client + .default_read_consistency_strategy + .as_deref() + .or(setup.runtime.default_read_consistency_strategy.as_deref()); + let inherited_uses_session = inherited_strategy + .map(parse_read_consistency) + .transpose()? + .is_none_or(|strategy| strategy == ReadConsistencyStrategy::Session); + let (session_token, expectation) = if inherited_uses_session { + ( + SessionTokenBehavior::SdkManaged, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), + ) + } else { + ( + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), ) - .await?; + }; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "inherits_client_then_runtime_then_account_default", + None, + session_token, + expectation, + ), + ) + .await +} - Ok(()) - }) +async fn default_override_restores_account_consistency( + setup: &SelectedLifecycleSetup<'_>, +) -> TestResult { + require_session_account(setup.account)?; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "default_override_restores_account_consistency", + Some(ReadConsistencyStrategy::Default), + SessionTokenBehavior::ExplicitCreateResponse, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), + ), + ) + .await +} + +async fn eventual_override_wins_over_defaults(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + require_session_account(setup.account)?; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "eventual_override_wins_over_all_defaults", + Some(ReadConsistencyStrategy::Eventual), + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), + ), + ) .await } +fn regional_read_expectation(account: &AccountDefinition) -> ReadExpectation { + if account.consistency == "strong" { + ReadExpectation::SucceedsImmediately + } else { + eventually_succeeds_after(TransientReadStatus::PlainNotFound) + } +} + +fn session_read_expectation(account: &AccountDefinition) -> ReadExpectation { + if account.consistency == "strong" { + ReadExpectation::SucceedsImmediately + } else { + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) + } +} + +fn require_session_account(account: &AccountDefinition) -> TestResult { + if account.consistency == "session" { + Ok(()) + } else { + Err(format!( + "readConsistencyOverrideMatrix requires a Session account, got '{}'", + account.consistency + ) + .into()) + } +} + // Operation cases ------------------------------------------------------------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -194,140 +407,6 @@ impl PostCreateReadCase { } } -fn read_cases_for_selected_profile( - setup: &SelectedLifecycleSetup<'_>, -) -> TestResult> { - match setup.profile.id.as_str() { - "smokeTests" => Ok(vec![PostCreateReadCase::new( - "default_strategy_reads_created_item", - Some(ReadConsistencyStrategy::Default), - SessionTokenBehavior::SdkManaged, - ReadExpectation::SucceedsImmediately, - )]), - "lifecycleConsistencyMatrix" => Ok(read_cases_for_account_consistency(setup.account)), - "readConsistencyOverrideMatrix" => Ok(read_cases_for_default_precedence( - setup.account, - setup.runtime.default_read_consistency_strategy.as_deref(), - setup.client.default_read_consistency_strategy.as_deref(), - )?), - profile => Err(format!("item.lifecycle does not implement profile '{profile}'").into()), - } -} - -fn read_cases_for_account_consistency(account: &AccountDefinition) -> Vec { - let is_strong_account = account.consistency == "strong"; - - let regional_read = if is_strong_account { - ReadExpectation::SucceedsImmediately - } else { - eventually_succeeds_after(TransientReadStatus::PlainNotFound) - }; - let session_read = if is_strong_account { - ReadExpectation::SucceedsImmediately - } else { - eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) - }; - let account_default_read = match account.consistency.as_str() { - "strong" => ReadExpectation::SucceedsImmediately, - "session" => session_read, - _ => regional_read, - }; - let account_default_token = if account.consistency == "session" { - SessionTokenBehavior::SdkManaged - } else { - SessionTokenBehavior::Omitted - }; - let global_strong_read = if is_strong_account { - ReadExpectation::SucceedsImmediately - } else { - ReadExpectation::RejectedBeforeTransport - }; - - vec![ - PostCreateReadCase::new( - "default_strategy_uses_account_consistency", - Some(ReadConsistencyStrategy::Default), - account_default_token, - account_default_read, - ), - PostCreateReadCase::new( - "eventual_strategy_allows_replication_lag", - Some(ReadConsistencyStrategy::Eventual), - SessionTokenBehavior::Omitted, - regional_read, - ), - PostCreateReadCase::new( - "session_strategy_uses_create_token", - Some(ReadConsistencyStrategy::Session), - SessionTokenBehavior::ExplicitCreateResponse, - session_read, - ), - PostCreateReadCase::new( - "latest_committed_is_region_local", - Some(ReadConsistencyStrategy::LatestCommitted), - SessionTokenBehavior::Omitted, - regional_read, - ), - PostCreateReadCase::new( - "global_strong_requires_strong_account", - Some(ReadConsistencyStrategy::GlobalStrong), - SessionTokenBehavior::Omitted, - global_strong_read, - ), - ] -} - -fn read_cases_for_default_precedence( - account: &AccountDefinition, - runtime_default: Option<&str>, - client_default: Option<&str>, -) -> TestResult> { - if account.consistency != "session" { - return Err(format!( - "readConsistencyOverrideMatrix requires a Session account, got '{}'", - account.consistency - ) - .into()); - } - // Effective precedence is operation > client > runtime > account. These cases vary only the - // operation value; the JSON profile supplies the selected client and runtime defaults. - let inherited_uses_session = match client_default.or(runtime_default) { - Some(strategy) => parse_read_consistency(strategy)? == ReadConsistencyStrategy::Session, - None => account.consistency == "session", - }; - let inherited_expectation = if inherited_uses_session { - eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) - } else { - eventually_succeeds_after(TransientReadStatus::PlainNotFound) - }; - let inherited_token = if inherited_uses_session { - SessionTokenBehavior::SdkManaged - } else { - SessionTokenBehavior::Omitted - }; - - Ok(vec![ - PostCreateReadCase::new( - "inherits_client_then_runtime_then_account_default", - None, - inherited_token, - inherited_expectation, - ), - PostCreateReadCase::new( - "default_override_restores_account_consistency", - Some(ReadConsistencyStrategy::Default), - SessionTokenBehavior::ExplicitCreateResponse, - eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), - ), - PostCreateReadCase::new( - "eventual_override_wins_over_all_defaults", - Some(ReadConsistencyStrategy::Eventual), - SessionTokenBehavior::Omitted, - eventually_succeeds_after(TransientReadStatus::PlainNotFound), - ), - ]) -} - fn eventually_succeeds_after(status: TransientReadStatus) -> ReadExpectation { ReadExpectation::EventuallySucceeds { allowed_transient_statuses: match status { @@ -785,178 +864,6 @@ fn parse_read_consistency(value: &str) -> TestResult { mod tests { use super::*; - #[test] - fn account_consistency_cases_cover_every_account_and_strategy() { - let profile = serde_json::from_str::(include_str!( - "../../../e2e_tests/profiles/lifecycleConsistencyMatrix.json" - )) - .expect("consistency profile must deserialize"); - let immediate = ReadExpectation::SucceedsImmediately; - let plain_not_found = eventually_succeeds_after(TransientReadStatus::PlainNotFound); - let session_not_available = - eventually_succeeds_after(TransientReadStatus::SessionNotAvailable); - let rejected = ReadExpectation::RejectedBeforeTransport; - let expected_by_account = [ - ("strong", [immediate; 5]), - ( - "boundedStaleness", - [ - plain_not_found, - plain_not_found, - session_not_available, - plain_not_found, - rejected, - ], - ), - ( - "session", - [ - session_not_available, - plain_not_found, - session_not_available, - plain_not_found, - rejected, - ], - ), - ( - "consistentPrefix", - [ - plain_not_found, - plain_not_found, - session_not_available, - plain_not_found, - rejected, - ], - ), - ( - "eventual", - [ - plain_not_found, - plain_not_found, - session_not_available, - plain_not_found, - rejected, - ], - ), - ]; - - for (account, expected) in expected_by_account { - let cases = read_cases_for_account_consistency(profile.account(account)); - assert_eq!( - cases.iter().map(|case| case.name).collect::>(), - [ - "default_strategy_uses_account_consistency", - "eventual_strategy_allows_replication_lag", - "session_strategy_uses_create_token", - "latest_committed_is_region_local", - "global_strong_requires_strong_account", - ], - "case names for {account}" - ); - assert_eq!( - cases - .iter() - .map(|case| case.consistency_override) - .collect::>(), - [ - Some(ReadConsistencyStrategy::Default), - Some(ReadConsistencyStrategy::Eventual), - Some(ReadConsistencyStrategy::Session), - Some(ReadConsistencyStrategy::LatestCommitted), - Some(ReadConsistencyStrategy::GlobalStrong), - ], - "operation consistency overrides for {account}" - ); - assert_eq!( - cases - .iter() - .map(|case| case.expectation) - .collect::>(), - expected, - "read expectations for {account}" - ); - assert_eq!( - cases - .iter() - .map(|case| case.session_token) - .collect::>(), - [ - if account == "session" { - SessionTokenBehavior::SdkManaged - } else { - SessionTokenBehavior::Omitted - }, - SessionTokenBehavior::Omitted, - SessionTokenBehavior::ExplicitCreateResponse, - SessionTokenBehavior::Omitted, - SessionTokenBehavior::Omitted, - ], - "session-token behavior for {account}" - ); - } - } - - #[test] - fn override_cases_show_client_runtime_account_precedence() { - let profile = serde_json::from_str::(include_str!( - "../../../e2e_tests/profiles/readConsistencyOverrideMatrix.json" - )) - .expect("override profile must deserialize"); - let account = profile.account("session"); - - for runtime in &profile.runtimes { - for client in &profile.clients { - let inherited_uses_session = client - .default_read_consistency_strategy - .as_deref() - .or(runtime.default_read_consistency_strategy.as_deref()) - .is_none_or(|strategy| strategy == "Session"); - let inherited_expectation = if inherited_uses_session { - eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) - } else { - eventually_succeeds_after(TransientReadStatus::PlainNotFound) - }; - let inherited_token = if inherited_uses_session { - SessionTokenBehavior::SdkManaged - } else { - SessionTokenBehavior::Omitted - }; - - let actual = read_cases_for_default_precedence( - account, - runtime.default_read_consistency_strategy.as_deref(), - client.default_read_consistency_strategy.as_deref(), - ) - .expect("profile defaults must be valid"); - let expected = [ - PostCreateReadCase::new( - "inherits_client_then_runtime_then_account_default", - None, - inherited_token, - inherited_expectation, - ), - PostCreateReadCase::new( - "default_override_restores_account_consistency", - Some(ReadConsistencyStrategy::Default), - SessionTokenBehavior::ExplicitCreateResponse, - eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), - ), - PostCreateReadCase::new( - "eventual_override_wins_over_all_defaults", - Some(ReadConsistencyStrategy::Eventual), - SessionTokenBehavior::Omitted, - eventually_succeeds_after(TransientReadStatus::PlainNotFound), - ), - ]; - assert_eq!( - actual, expected, - "runtime '{}' and client '{}'", - runtime.id, client.id - ); - } - } - } - #[test] fn status_matching_distinguishes_wildcard_zero_and_exact_substatus() { assert!(READ_SUCCEEDED.matches(StatusCode::Ok, Some(42))); diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs index 68b62f1fc2a..a3ff59d21b6 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs @@ -4,7 +4,7 @@ use azure_core::http::StatusCode; use crate::e2e_test_cases::{ - fixture::{E2eTestFixture, TestResult}, + fixture::{E2eTest, TestResult}, support::{assert_critical_diagnostics, item, should_run, Item}, }; @@ -17,35 +17,36 @@ async fn not_found_does_not_cross_partition_keys() -> TestResult { if !should_run("item.not-found-wrong-partition-key").await? { return Ok(()); } - E2eTestFixture::run(async |fixture| { - // Arrange one item in logical partition A. - fixture - .container - .create_item("A", "item-1", item("item-1", "A", 1), None) - .await?; + E2eTest::builder() + .run(async |fixture| { + // Arrange one item in logical partition A. + fixture + .container + .create_item("A", "item-1", item("item-1", "A", 1), None) + .await?; - // Case 1: a missing ID in the correct partition returns a plain 404. - let missing = fixture - .container - .read_item("A", "missing", None) - .await - .expect_err("missing item read must fail"); - assert_plain_not_found(&missing); + // Case 1: a missing ID in the correct partition returns a plain 404. + let missing = fixture + .container + .read_item("A", "missing", None) + .await + .expect_err("missing item read must fail"); + assert_plain_not_found(&missing); - // Case 2: the existing ID cannot be read through a different partition key. - let wrong_partition = fixture - .container - .read_item("B", "item-1", None) - .await - .expect_err("wrong-partition-key read must fail"); - assert_plain_not_found(&wrong_partition); + // Case 2: the existing ID cannot be read through a different partition key. + let wrong_partition = fixture + .container + .read_item("B", "item-1", None) + .await + .expect_err("wrong-partition-key read must fail"); + assert_plain_not_found(&wrong_partition); - // Neither failed read mutated or hid the correctly addressed item. - let read = fixture.container.read_item("A", "item-1", None).await?; - assert_eq!(read.into_model::()?, item("item-1", "A", 1)); - Ok(()) - }) - .await + // Neither failed read mutated or hid the correctly addressed item. + let read = fixture.container.read_item("A", "item-1", None).await?; + assert_eq!(read.into_model::()?, item("item-1", "A", 1)); + Ok(()) + }) + .await } fn assert_plain_not_found(error: &azure_data_cosmos::CosmosError) { diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs index f9ce2bb7953..a019c219538 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs @@ -5,7 +5,7 @@ use azure_core::http::{Etag, StatusCode}; use azure_data_cosmos::options::{ItemWriteOptions, Precondition}; use crate::e2e_test_cases::{ - fixture::{E2eTestFixture, TestResult}, + fixture::{E2eTest, TestResult}, support::{assert_critical_diagnostics, item, should_run, Item}, }; @@ -18,62 +18,63 @@ async fn stale_etag_preserves_successful_update() -> TestResult { if !should_run("item.optimistic-concurrency").await? { return Ok(()); } - E2eTestFixture::run(async |fixture| { - // Arrange an item and retain its initial ETag. - let created = fixture - .container - .create_item("A", "etag-1", item("etag-1", "A", 1), None) - .await?; - let initial_etag = created - .headers() - .etag() - .expect("create must return an ETag") - .clone(); - - // Case 1: the current ETag permits the update from value 1 to value 2. - let current_options = ItemWriteOptions::default() - .with_precondition(Precondition::IfMatch(initial_etag.clone())); - let replaced = fixture - .container - .replace_item("A", "etag-1", item("etag-1", "A", 2), Some(current_options)) - .await?; - assert_eq!(replaced.status().status_code(), StatusCode::Ok); + E2eTest::builder() + .run(async |fixture| { + // Arrange an item and retain its initial ETag. + let created = fixture + .container + .create_item("A", "etag-1", item("etag-1", "A", 1), None) + .await?; + let initial_etag = created + .headers() + .etag() + .expect("create must return an ETag") + .clone(); - // Case 2: reusing the now-stale initial ETag cannot overwrite value 2. - let stale_options = ItemWriteOptions::default() - .with_precondition(Precondition::IfMatch(Etag::from(initial_etag.to_string()))); - let error = fixture - .container - .replace_item("A", "etag-1", item("etag-1", "A", 3), Some(stale_options)) - .await - .expect_err("stale ETag must fail"); - assert_eq!(error.status().status_code(), StatusCode::PreconditionFailed); - assert_eq!( - error - .status() - .sub_status() - .map(|value| value.value()) - .unwrap_or(0), - 0 - ); - assert_critical_diagnostics( - &error - .diagnostics() - .expect("service error must carry diagnostics"), - "replace_item", - StatusCode::PreconditionFailed, - ); + // Case 1: the current ETag permits the update from value 1 to value 2. + let current_options = ItemWriteOptions::default() + .with_precondition(Precondition::IfMatch(initial_etag.clone())); + let replaced = fixture + .container + .replace_item("A", "etag-1", item("etag-1", "A", 2), Some(current_options)) + .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); - // The rejected write leaves the successful value 2 update intact. - assert_eq!( - fixture + // Case 2: reusing the now-stale initial ETag cannot overwrite value 2. + let stale_options = ItemWriteOptions::default() + .with_precondition(Precondition::IfMatch(Etag::from(initial_etag.to_string()))); + let error = fixture .container - .read_item("A", "etag-1", None) - .await? - .into_model::()?, - item("etag-1", "A", 2) - ); - Ok(()) - }) - .await + .replace_item("A", "etag-1", item("etag-1", "A", 3), Some(stale_options)) + .await + .expect_err("stale ETag must fail"); + assert_eq!(error.status().status_code(), StatusCode::PreconditionFailed); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + assert_critical_diagnostics( + &error + .diagnostics() + .expect("service error must carry diagnostics"), + "replace_item", + StatusCode::PreconditionFailed, + ); + + // The rejected write leaves the successful value 2 update intact. + assert_eq!( + fixture + .container + .read_item("A", "etag-1", None) + .await? + .into_model::()?, + item("etag-1", "A", 2) + ); + Ok(()) + }) + .await } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs index faf6c7df6c0..5ef7088997d 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs @@ -6,7 +6,7 @@ use azure_data_cosmos::{feed::FeedScope, Query}; use futures::TryStreamExt; use crate::e2e_test_cases::{ - fixture::{E2eTestFixture, TestResult}, + fixture::{E2eTest, TestResult}, support::{assert_critical_diagnostics, item, should_run, write_options_with_content, Item}, }; @@ -19,48 +19,49 @@ async fn upsert_creates_then_updates() -> TestResult { if !should_run("item.upsert-create-update").await? { return Ok(()); } - E2eTestFixture::run(async |fixture| { - // Case 1: upserting a missing identity creates value 1 and returns 201. - let created = fixture - .container - .upsert_item( - "A", - "upsert-1", - item("upsert-1", "A", 1), - Some(write_options_with_content()), - ) - .await?; - assert_eq!(created.status().status_code(), StatusCode::Created); - assert_critical_diagnostics(&created.diagnostics(), "upsert_item", StatusCode::Created); + E2eTest::builder() + .run(async |fixture| { + // Case 1: upserting a missing identity creates value 1 and returns 201. + let created = fixture + .container + .upsert_item( + "A", + "upsert-1", + item("upsert-1", "A", 1), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(created.status().status_code(), StatusCode::Created); + assert_critical_diagnostics(&created.diagnostics(), "upsert_item", StatusCode::Created); - // Case 2: upserting the same identity replaces it with value 2 and returns 200. - let updated = fixture - .container - .upsert_item( - "A", - "upsert-1", - item("upsert-1", "A", 2), - Some(write_options_with_content()), - ) - .await?; - assert_eq!(updated.status().status_code(), StatusCode::Ok); - assert_critical_diagnostics(&updated.diagnostics(), "upsert_item", StatusCode::Ok); - assert_eq!(updated.into_model::()?, item("upsert-1", "A", 2)); + // Case 2: upserting the same identity replaces it with value 2 and returns 200. + let updated = fixture + .container + .upsert_item( + "A", + "upsert-1", + item("upsert-1", "A", 2), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(updated.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&updated.diagnostics(), "upsert_item", StatusCode::Ok); + assert_eq!(updated.into_model::()?, item("upsert-1", "A", 2)); - // Both operations addressed one identity; no duplicate document was created. - let items: Vec = fixture - .container - .query_items( - Query::from("SELECT * FROM c WHERE c.id = @id") - .with_parameter("@id", "upsert-1")?, - FeedScope::partition("A"), - None, - ) - .await? - .try_collect() - .await?; - assert_eq!(items, [item("upsert-1", "A", 2)]); - Ok(()) - }) - .await + // Both operations addressed one identity; no duplicate document was created. + let items: Vec = fixture + .container + .query_items( + Query::from("SELECT * FROM c WHERE c.id = @id") + .with_parameter("@id", "upsert-1")?, + FeedScope::partition("A"), + None, + ) + .await? + .try_collect() + .await?; + assert_eq!(items, [item("upsert-1", "A", 2)]); + Ok(()) + }) + .await } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs index 3d2ad862bd3..33490771b5b 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs @@ -6,7 +6,7 @@ use azure_data_cosmos::feed::FeedScope; use futures::StreamExt; use crate::e2e_test_cases::{ - fixture::{E2eTestFixture, TestResult}, + fixture::{E2eTest, TestResult}, support::{should_run, Item}, }; @@ -19,24 +19,25 @@ async fn invalid_query_is_not_an_empty_feed() -> TestResult { if !should_run("query.invalid-syntax").await? { return Ok(()); } - E2eTestFixture::run(async |fixture| { - // Query failures may surface while creating the iterator or while reading its first page. - let result = fixture - .container - .query_items::("SELECT FROM", FeedScope::partition("A"), None) - .await; - let error = match result { - Err(error) => error, - Ok(mut stream) => stream - .next() - .await - .expect("invalid query must produce an error") - .expect_err("invalid query must not produce a page"), - }; + E2eTest::builder() + .run(async |fixture| { + // Query failures may surface while creating the iterator or while reading its first page. + let result = fixture + .container + .query_items::("SELECT FROM", FeedScope::partition("A"), None) + .await; + let error = match result { + Err(error) => error, + Ok(mut stream) => stream + .next() + .await + .expect("invalid query must produce an error") + .expect_err("invalid query must not produce a page"), + }; - // Invalid syntax is a typed bad request, never an empty successful feed. - assert_eq!(error.status().status_code(), StatusCode::BadRequest); - Ok(()) - }) - .await + // Invalid syntax is a typed bad request, never an empty successful feed. + assert_eq!(error.status().status_code(), StatusCode::BadRequest); + Ok(()) + }) + .await } diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs index c4797293cdd..0cdc84d98e8 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs @@ -5,7 +5,7 @@ use azure_data_cosmos::{feed::FeedScope, Query}; use futures::{StreamExt, TryStreamExt}; use crate::e2e_test_cases::{ - fixture::{E2eTestFixture, TestResult}, + fixture::{E2eTest, TestResult}, support::{item, should_run, Item}, }; @@ -18,54 +18,55 @@ async fn parameterized_query_filters_and_orders() -> TestResult { if !should_run("query.parameterized-filter").await? { return Ok(()); } - E2eTestFixture::run(async |fixture| { - // Arrange IDs and scores in different orders so ORDER BY is observable. - for (id, score) in [("a", 3), ("b", 1), ("c", 2)] { - let mut value = item(id, "A", score); - value.score = Some(score); - fixture.container.create_item("A", id, value, None).await?; - } + E2eTest::builder() + .run(async |fixture| { + // Arrange IDs and scores in different orders so ORDER BY is observable. + for (id, score) in [("a", 3), ("b", 1), ("c", 2)] { + let mut value = item(id, "A", score); + value.score = Some(score); + fixture.container.create_item("A", id, value, None).await?; + } - // Bind values as parameters and restrict execution to partition A. - let query = Query::from( - "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score ASC", - ) - .with_parameter("@pk", "A")? - .with_parameter("@min", 2)?; - let mut results = fixture - .container - .query_items::(query, FeedScope::partition("A"), None) - .await?; - let items: Vec = results.by_ref().try_collect().await?; + // Bind values as parameters and restrict execution to partition A. + let query = Query::from( + "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score ASC", + ) + .with_parameter("@pk", "A")? + .with_parameter("@min", 2)?; + let mut results = fixture + .container + .query_items::(query, FeedScope::partition("A"), None) + .await?; + let items: Vec = results.by_ref().try_collect().await?; - // The filter excludes score 1 and ordering differs from ID/insertion order. - assert_eq!( - items - .iter() - .map(|item| item.id.as_str()) - .collect::>(), - ["c", "a"] - ); + // The filter excludes score 1 and ordering differs from ID/insertion order. + assert_eq!( + items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["c", "a"] + ); - let descending = Query::from( - "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score DESC", - ) - .with_parameter("@pk", "A")? - .with_parameter("@min", 2)?; - let descending_items: Vec = fixture - .container - .query_items::(descending, FeedScope::partition("A"), None) - .await? - .try_collect() - .await?; - assert_eq!( - descending_items - .iter() - .map(|item| item.id.as_str()) - .collect::>(), - ["a", "c"] - ); - Ok(()) - }) - .await + let descending = Query::from( + "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score DESC", + ) + .with_parameter("@pk", "A")? + .with_parameter("@min", 2)?; + let descending_items: Vec = fixture + .container + .query_items::(descending, FeedScope::partition("A"), None) + .await? + .try_collect() + .await?; + assert_eq!( + descending_items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["a", "c"] + ); + Ok(()) + }) + .await }