diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py index a0864ac37..410e337da 100644 --- a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py @@ -1,27 +1,33 @@ -""" -Smoke test for writeConcern. +"""writeConcern smoke test: a basic insert with w:1 succeeds.""" -Tests basic writeConcern functionality. -""" +from typing import Any, Dict, cast import pytest -from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq pytestmark = pytest.mark.smoke - -def test_smoke_write_concern(collection): - """Test basic writeConcern behavior.""" - result = execute_command( - collection, - { - "insert": collection.name, +SMOKE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "insert_w1", + command={ "documents": [{"_id": 1, "name": "test"}], "writeConcern": {"w": 1}, }, - ) + expected={"ok": Eq(1.0), "n": Eq(1)}, + msg="Should support writeConcern", + ), +] - expected = {"ok": 1.0, "n": 1} - assertSuccessPartial(result, expected, msg="Should support writeConcern") + +@pytest.mark.parametrize("test", pytest_params(SMOKE_TESTS)) +def test_smoke_write_concern(collection, test: CommandTestCase): + """Test basic writeConcern behavior.""" + insert_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"insert": collection.name, **insert_body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py new file mode 100644 index 000000000..b1243980d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py @@ -0,0 +1,235 @@ +"""writeConcern acceptance: valid w/j/wtimeout/provenance values and their +combinations, plus writeConcern:null behaving like an omitted writeConcern. +""" + +from typing import Any, Dict, cast + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, +) + +W_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_0", + command={"updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": 0}}, + expected={"ok": Eq(1.0)}, + msg="w:0 should be accepted.", + ), + CommandTestCase( + "w_double_coerced", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1.0}, + }, + expected={"ok": Eq(1.0)}, + msg="w:1.0 should coerce and be accepted.", + ), + CommandTestCase( + "w_int64_coerced", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Int64(1)}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Int64 should coerce and be accepted.", + ), + CommandTestCase( + "w_decimal128_coerced", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Decimal128("1")}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Decimal128 should coerce and be accepted.", + ), + CommandTestCase( + "w_int64_0", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Int64(0)}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Int64(0) should be accepted.", + ), + CommandTestCase( + "w_negative_zero", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": -0.0}, + }, + expected={"ok": Eq(1.0)}, + msg="w:-0.0 should be accepted.", + ), + CommandTestCase( + "w_fractional_0_5", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0.5}, + }, + expected={"ok": Eq(1.0)}, + msg="w:0.5 should be accepted.", + ), + CommandTestCase( + "w_fractional_1_5", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1.5}, + }, + expected={"ok": Eq(1.0)}, + msg="w:1.5 should be accepted.", + ), +] + + +WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "int32_max", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": INT32_MAX}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout INT32_MAX ok.", + ), + CommandTestCase( + "int32_min", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": INT32_MIN}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout INT32_MIN ok.", + ), + CommandTestCase( + "negative_inf", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": FLOAT_NEGATIVE_INFINITY}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout -Infinity ok.", + ), + CommandTestCase( + "decimal128_neg_inf", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": DECIMAL128_NEGATIVE_INFINITY}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout Decimal128 -Infinity ok.", + ), + CommandTestCase( + "zero", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": 0}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout 0 ok.", + ), + CommandTestCase( + "negative", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": -1}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout negative ok.", + ), + CommandTestCase( + "with_w0", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0, "wtimeout": 5_000}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout with w:0 ok.", + ), +] + + +# Sub-fields compose in one writeConcern document. +COMBINATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_three", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "j": True, "wtimeout": 5_000}, + }, + expected={"ok": Eq(1.0)}, + msg="w + j + wtimeout together should be accepted.", + ), +] + + +# provenance acceptance (a representative value plus null). +PROVENANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clientSupplied", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": "clientSupplied"}, + }, + expected={"ok": Eq(1.0)}, + msg="provenance:'clientSupplied' should be accepted.", + ), + CommandTestCase( + "null", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": None}, + }, + expected={"ok": Eq(1.0)}, + msg="provenance:null should be accepted.", + ), +] + +WRITE_CONCERN_ACCEPTANCE_TESTS = ( + W_ACCEPTANCE_TESTS + WTIMEOUT_ACCEPTANCE_TESTS + COMBINATION_TESTS + PROVENANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_ACCEPTANCE_TESTS)) +def test_write_concern_accepted(collection, test: CommandTestCase): + """Test writeConcern accepts valid sub-field values and combinations.""" + collection.insert_one({"_id": 1, "a": 0}) + update_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"update": collection.name, **update_body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) + + +def test_write_concern_null_equivalent_to_omitted(collection): + """Test writeConcern null produces the same response as omitting writeConcern.""" + collection.insert_many([{"_id": 1, "a": 0}, {"_id": 2, "a": 0}]) + omitted = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + }, + ) + explicit_null = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 2}, "u": {"$set": {"a": 1}}}], + "writeConcern": None, + }, + ) + expected = {k: omitted[k] for k in ("ok", "n", "nModified") if k in omitted} + assertSuccessPartial( + explicit_null, + expected, + msg="update with writeConcern:null should match an omitted writeConcern.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py new file mode 100644 index 000000000..ef6e8915b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py @@ -0,0 +1,186 @@ +"""writeConcern behavior: w:0 unacknowledged writes, j:true overriding w:0, +findAndModify return semantics, and ordered interaction. + +The "still performs the write" checks are hand-written (not CommandTestCase +rows) because they verify the effect with a second find command. +""" + +from typing import Any, Dict, cast + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists + +BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "update_j_true_overrides_w0", + docs=[{"_id": 1}], + command={ + "verb": "update", + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0, "j": True}, + }, + expected={"ok": Eq(1.0)}, + msg="update with j:true should override w:0.", + ), + CommandTestCase( + "delete_j_true_overrides_w0", + docs=[{"_id": 99}], + command={ + "verb": "delete", + "deletes": [{"q": {"_id": 99}, "limit": 1}], + "writeConcern": {"w": 0, "j": True}, + }, + expected={"ok": Eq(1.0)}, + msg="delete with j:true should override w:0.", + ), + CommandTestCase( + "findAndModify_j_true_overrides_w0", + docs=[{"_id": 1}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 1}}, + "writeConcern": {"w": 0, "j": True}, + }, + expected={"ok": Eq(1.0)}, + msg="findAndModify with j:true should override w:0.", + ), + CommandTestCase( + "findAndModify_w0_accepts_and_performs_write", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": True, + "writeConcern": {"w": 0}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(99)}}, + msg="findAndModify with w:0 should still perform the write.", + ), + CommandTestCase( + "findAndModify_new_true", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": True, + "writeConcern": {"w": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(99)}}, + msg="findAndModify new:true should return modified doc.", + ), + CommandTestCase( + "findAndModify_new_false", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": False, + "writeConcern": {"w": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(0)}}, + msg="findAndModify new:false should return original doc.", + ), + CommandTestCase( + "findAndModify_remove", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "remove": True, + "writeConcern": {"w": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(0)}}, + msg="findAndModify remove:true should return removed doc.", + ), + CommandTestCase( + "update_ordered_true", + docs=[{"_id": 1}], + command={ + "verb": "update", + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "ordered": True, + "writeConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="update with ordered:true and writeConcern should succeed.", + ), + CommandTestCase( + "update_ordered_false", + docs=[{"_id": 1}], + command={ + "verb": "update", + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "ordered": False, + "writeConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="update with ordered:false and writeConcern should succeed.", + ), + # w:0 suppresses the per-operation error that w:1 surfaces. The w:1 "surfaces" + # counterpart lives in test_write_concern_errors.py (w1_surfaces_operation_error). + # multi:true with a replacement doc is the invalid operation under test. + CommandTestCase( + "w0_suppresses_operation_error", + docs=[{"_id": 1, "a": 1}], + command={ + "verb": "update", + "updates": [{"q": {}, "u": {"a": 2}, "multi": True}], + "writeConcern": {"w": 0}, + }, + expected={"writeErrors": NotExists()}, + msg="update with w:0 should suppress the operation error.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BEHAVIOR_TESTS)) +def test_write_concern_behavior(collection, test: CommandTestCase): + """Test single-command writeConcern behaviors.""" + collection = test.prepare(collection.database, collection) + body = dict(cast(Dict[str, Any], test.command)) + verb = body.pop("verb") + result = execute_command(collection, {verb: collection.name, **body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) + + +def test_update_w0_performs_write(collection): + """Test update with w:0 still performs the write.""" + collection.insert_one({"_id": 1, "a": 0}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 99}}}], + "writeConcern": {"w": 0}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertResult( + result, + expected=[{"_id": 1, "a": 99}], + msg="update with w:0 should still perform the write.", + ) + + +def test_delete_w0_performs_delete(collection): + """Test delete with w:0 still performs the delete.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "delete": collection.name, + "deletes": [{"q": {"_id": 1}, "limit": 1}], + "writeConcern": {"w": 0}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertResult(result, expected=[], msg="delete with w:0 should still perform the delete.") diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py new file mode 100644 index 000000000..24583765d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py @@ -0,0 +1,112 @@ +"""writeConcern BSON type validation: the writeConcern field accepts only a +document or null, and the w/j/wtimeout sub-fields accept their supported BSON +types; all other types are rejected. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import FAILED_TO_PARSE_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command + +WRITE_CONCERN_PARAMS = [ + BsonTypeTestCase( + id="write_concern_field", + msg="writeConcern should reject non-document types", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"w": 1}}, + ), +] + +SUB_FIELD_PARAMS = [ + BsonTypeTestCase( + id="w", + msg="w should accept numbers, 'majority', and tagged objects", + valid_types=[ + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.STRING, + BsonType.OBJECT, + ], + skip_rejection_types=[BsonType.NULL], + default_error_code=FAILED_TO_PARSE_ERROR, + valid_inputs={ + BsonType.INT: 1, + BsonType.LONG: Int64(1), + BsonType.DOUBLE: 1.0, + BsonType.DECIMAL: Decimal128("1"), + BsonType.STRING: "majority", + BsonType.OBJECT: {"dc1": 1}, + }, + ), + BsonTypeTestCase( + id="j", + msg="j should accept boolean, numeric types, and null", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="wtimeout", + msg="wtimeout should accept all BSON types", + valid_types=list(BsonType), + default_error_code=FAILED_TO_PARSE_ERROR, + valid_inputs={BsonType.LONG: Int64(5_000)}, + ), +] + + +def _build_command(collection_name, spec, sample_value): + """Build an update command placing the sample value per the spec.""" + if spec.id == "write_concern_field": + write_concern = sample_value + elif spec.id == "w": + write_concern = {"w": sample_value} + else: + write_concern = {"w": 1, spec.id: sample_value} + return { + "update": collection_name, + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": write_concern, + } + + +_ALL_PARAMS = WRITE_CONCERN_PARAMS + SUB_FIELD_PARAMS + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(_ALL_PARAMS) +) +def test_write_concern_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test the writeConcern field and sub-fields accept their supported BSON types.""" + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertSuccessPartial(result, {"ok": 1.0}, msg=f"{spec.id} should accept {bson_type.value}") + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(_ALL_PARAMS) +) +def test_write_concern_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test the writeConcern field and sub-fields reject unsupported BSON types.""" + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertResult( + result, + error_code=spec.expected_code(bson_type), + msg=f"{spec.id} should reject {bson_type.value}", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py new file mode 100644 index 000000000..e3ce10cc1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py @@ -0,0 +1,281 @@ +"""writeConcern rejection: invalid w/j/wtimeout values, case-sensitive w strings, +invalid provenance, and unknown fields, plus the w:1 case where a per-operation +error surfaces. Each is expected to fail with a specific error code. BSON-type +rejection lives in test_write_concern_bson_type_validation.py. +""" + +from typing import Any, Dict, cast + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_MAX, + INT32_OVERFLOW, + INT64_MAX, + INT64_MIN, +) + +# These cases are only rejected on standalone; a quorum target treats them as +# custom tag names (valid), so they are deselected there. +_STANDALONE_ONLY = (pytest.mark.requires(quorum_write_concern=False),) + +WRITE_CONCERN_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_null", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": None}}, + error_code=BAD_VALUE_ERROR, + msg="w:null should be rejected on standalone.", + marks=_STANDALONE_ONLY, + ), + CommandTestCase( + "w_negative", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": -1}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w:-1 should be rejected.", + ), + CommandTestCase( + "w_exceeds_50", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": 51}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w:51 should be rejected.", + ), + CommandTestCase( + "w_float_nan", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": FLOAT_NAN}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as NaN should be rejected.", + ), + CommandTestCase( + "w_float_neg_nan", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": FLOAT_NEGATIVE_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as -NaN should be rejected.", + ), + CommandTestCase( + "w_decimal128_nan", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 NaN should be rejected.", + ), + CommandTestCase( + "w_decimal128_neg_nan", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_NEGATIVE_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 -NaN should be rejected.", + ), + CommandTestCase( + "w_float_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": FLOAT_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as +Infinity should be rejected.", + ), + CommandTestCase( + "w_float_neg_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": FLOAT_NEGATIVE_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as -Infinity should be rejected.", + ), + CommandTestCase( + "w_decimal128_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 +Infinity should be rejected.", + ), + CommandTestCase( + "w_decimal128_neg_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_NEGATIVE_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 -Infinity should be rejected.", + ), + CommandTestCase( + "w_tagged_non_numeric", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": {"dc1": "hello"}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w tagged object with non-numeric value should be rejected.", + ), + CommandTestCase( + "w_int64_max", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": INT64_MAX}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Int64 max should be rejected.", + ), + CommandTestCase( + "w_int64_min", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": INT64_MIN}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Int64 min should be rejected.", + ), + CommandTestCase( + "w_tagged_empty_object", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": {}}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="empty object w should be rejected (tagged write concern requires tags).", + ), + CommandTestCase( + "w_tagged_nested_object", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": {"dc1": {"nested": 1}}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="tagged w with nested object value should be rejected.", + ), + CommandTestCase( + "wtimeout_int64_overflow", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": Int64(INT32_MAX + 1)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Int64 wtimeout exceeding INT32_MAX should be rejected.", + ), + CommandTestCase( + "wtimeout_double_overflow", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": float(INT32_OVERFLOW)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="double wtimeout exceeding INT32_MAX should be rejected.", + ), + CommandTestCase( + "wtimeout_decimal128_overflow", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": Decimal128(str(INT32_OVERFLOW))}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Decimal128 wtimeout exceeding INT32_MAX should be rejected.", + ), + CommandTestCase( + "wtimeout_float_infinity", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": FLOAT_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="+Infinity wtimeout (exceeds INT32_MAX) should be rejected.", + ), + # Property [Unknown Field Rejection]: unrecognized fields in writeConcern are rejected. + CommandTestCase( + "unknown_field", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "unknownField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="writeConcern should reject unrecognized fields.", + ), + CommandTestCase( + "provenance_invalid_string", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="provenance with an unknown string value should be rejected.", + ), + CommandTestCase( + "provenance_wrong_type", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": 42}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="provenance with a non-string value should be rejected.", + ), + # A valid writeConcern (w:1) surfaces a per-operation error: multi:true with a + # replacement doc is invalid. The w:0 "suppresses" counterpart lives in + # test_write_concern_behavior.py (w0_suppresses_operation_error). + CommandTestCase( + "w1_surfaces_operation_error", + docs=[{"_id": 1, "a": 1}], + command={ + "updates": [{"q": {}, "u": {"a": 2}, "multi": True}], + "writeConcern": {"w": 1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="update with w:1 should surface the operation error.", + ), + # w "majority" is case-sensitive and an empty string is a custom tag; both are + # only rejected on standalone (see _STANDALONE_ONLY). + CommandTestCase( + "w_wrong_case_Majority", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": "Majority"}, + }, + error_code=BAD_VALUE_ERROR, + msg="w:'Majority' should be rejected (case-sensitive).", + marks=_STANDALONE_ONLY, + ), + CommandTestCase( + "w_all_caps_MAJORITY", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": "MAJORITY"}, + }, + error_code=BAD_VALUE_ERROR, + msg="w:'MAJORITY' should be rejected (case-sensitive).", + marks=_STANDALONE_ONLY, + ), + CommandTestCase( + "w_empty_string", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": ""}}, + error_code=BAD_VALUE_ERROR, + msg="w:'' (empty string custom tag) should be rejected on standalone.", + marks=_STANDALONE_ONLY, + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_REJECTION_TESTS)) +def test_write_concern_rejected(collection, test: CommandTestCase): + """Test writeConcern rejects invalid sub-field values and unknown fields.""" + collection = test.prepare(collection.database, collection) + update_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"update": collection.name, **update_body}) + assertResult(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py new file mode 100644 index 000000000..b8d181115 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py @@ -0,0 +1,53 @@ +"""writeConcern w values > 1 (up to 50), accepted only on a replica set. +Deselected on standalone targets. +""" + +from typing import Any, Dict, cast + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.requires(quorum_write_concern=True) + + +W_REPLICA_SET_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_2", + command={"updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": 2}}, + expected={"ok": Eq(1.0)}, + msg="w:2 should be accepted on a replica set.", + ), + CommandTestCase( + "w_50_max", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 50}, + }, + expected={"ok": Eq(1.0)}, + msg="w:50 (max) should be accepted on a replica set.", + ), + CommandTestCase( + "w_int64_50", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Int64(50)}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Int64(50) should be accepted on a replica set.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(W_REPLICA_SET_TESTS)) +def test_write_concern_w_replica_set(collection, test: CommandTestCase): + """Test writeConcern accepts w values requiring a replica set.""" + collection.insert_one({"_id": 1, "a": 0}) + update_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"update": collection.name, **update_body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True)