diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local_availability.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local_availability.py new file mode 100644 index 000000000..cc6ef9104 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local_availability.py @@ -0,0 +1,95 @@ +""" +readConcern level: "local" availability tests. + +Tests that readConcern "local" is available with and without causally consistent +sessions, and on primary reads. Per the MongoDB spec: + "local" is available with or without causally consistent sessions and transactions. + It is the default for reads against the primary and secondaries. +""" + +from documentdb_tests.framework.assertions import assertNotError +from documentdb_tests.framework.executor import execute_command + + +def test_read_concern_local_available_without_session(collection): + """Test readConcern 'local' is available for a find without any session.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": {"level": "local"}, + }, + ) + assertNotError(result, msg="readConcern 'local' should be available without a session.") + + +def test_read_concern_local_available_on_aggregate(collection): + """Test readConcern 'local' is available on aggregate without a session.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [], + "cursor": {}, + "readConcern": {"level": "local"}, + }, + ) + assertNotError(result, msg="readConcern 'local' should be available on aggregate.") + + +def test_read_concern_local_available_on_count(collection): + """Test readConcern 'local' is available on count without a session.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "count": collection.name, + "query": {}, + "readConcern": {"level": "local"}, + }, + ) + assertNotError(result, msg="readConcern 'local' should be available on count.") + + +def test_read_concern_local_available_on_distinct(collection): + """Test readConcern 'local' is available on distinct without a session.""" + collection.insert_one({"_id": 1, "x": "a"}) + result = execute_command( + collection, + { + "distinct": collection.name, + "key": "x", + "readConcern": {"level": "local"}, + }, + ) + assertNotError(result, msg="readConcern 'local' should be available on distinct.") + + +def test_read_concern_local_available_on_empty_collection(collection): + """Test readConcern 'local' is available even when the collection is empty.""" + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": {"level": "local"}, + }, + ) + assertNotError(result, msg="readConcern 'local' should be available on an empty collection.") + + +def test_read_concern_local_does_not_error(collection): + """Test readConcern 'local' does not produce an error on find.""" + collection.insert_many([{"_id": i} for i in range(3)]) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": {"level": "local"}, + }, + ) + assertNotError(result, msg="find with readConcern 'local' should not error.") 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..a42704b9c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py @@ -0,0 +1,279 @@ +""" +writeConcern acceptance tests. + +Tests that valid writeConcern values are accepted across write commands, including +w sub-field coercions, j truthiness, wtimeout acceptance, and sub-field combinations. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.query_and_write.write_concern.utils import ( + WRITE_COMMANDS, + build_cmd, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertNotError +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, +) + +_VALID_WC_VALUES = [ + ("null", None), + ("empty_doc", {}), + ("w_1", {"w": 1}), + ("w_0", {"w": 0}), + ("w_majority", {"w": "majority"}), + ("w_double_coerced", {"w": 1.0}), + ("w_int64_coerced", {"w": Int64(1)}), + ("w_decimal128_coerced", {"w": Decimal128("1")}), + ("w_tagged_object", {"w": {"dc1": 1}}), + ("w_negative_zero", {"w": -0.0}), + ("w_decimal128_neg_zero", {"w": Decimal128("-0")}), + ("w_decimal128_zero_exponent", {"w": Decimal128("0E+3")}), + ("w_decimal128_one_exponent", {"w": Decimal128("1E+0")}), + ("w_decimal128_one_decimal", {"w": Decimal128("1.0")}), + ("w_int64_0", {"w": Int64(0)}), + ("w_fractional_1_5", {"w": 1.5}), + ("w_fractional_0_5", {"w": 0.5}), +] + +# Property [writeConcern Acceptance]: write commands accept valid writeConcern values. +WC_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_accepts_wc_{val_name}", + docs=[{"_id": 1}], + command=lambda ctx, _wc=value, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + msg=f"{cmd} should accept writeConcern {val_name}.", + ) + for cmd in WRITE_COMMANDS + for val_name, value in _VALID_WC_VALUES +] + + +@pytest.mark.parametrize("test", pytest_params(WC_ACCEPTANCE_TESTS)) +def test_write_concern_accepted(collection, test: CommandTestCase): + """Test write commands accept valid writeConcern values.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertNotError(result, msg=test.msg) + + +_VALID_J_VALUES = [ + ("j_true", {"w": 1, "j": True}), + ("j_false", {"w": 1, "j": False}), + ("j_null", {"w": 1, "j": None}), + ("j_int32", {"w": 1, "j": 42}), + ("j_int64", {"w": 1, "j": Int64(1)}), + ("j_double", {"w": 1, "j": 3.14}), + ("j_decimal128", {"w": 1, "j": Decimal128("1")}), +] + +# Property [j Acceptance]: j accepts boolean and numeric types via truthiness coercion. +J_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_accepts_{val_name}", + docs=[{"_id": 1}], + command=lambda ctx, _wc=value, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + msg=f"{cmd} should accept writeConcern {val_name}.", + ) + for cmd in WRITE_COMMANDS + for val_name, value in _VALID_J_VALUES +] + + +@pytest.mark.parametrize("test", pytest_params(J_ACCEPTANCE_TESTS)) +def test_write_concern_j_accepted(collection, test: CommandTestCase): + """Test j sub-field accepts boolean and numeric types.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertNotError(result, msg=test.msg) + + +_VALID_WTIMEOUT_VALUES = [ + ("int", {"w": 1, "wtimeout": 5_000}), + ("int64", {"w": 1, "wtimeout": Int64(5_000)}), + ("double", {"w": 1, "wtimeout": 5000.0}), + ("decimal128", {"w": 1, "wtimeout": Decimal128("5000")}), + ("string", {"w": 1, "wtimeout": "hello"}), + ("bool", {"w": 1, "wtimeout": True}), + ("array", {"w": 1, "wtimeout": [1, 2]}), + ("object", {"w": 1, "wtimeout": {"a": 1}}), + ("null", {"w": 1, "wtimeout": None}), + ("objectId", {"w": 1, "wtimeout": ObjectId()}), + ("binary", {"w": 1, "wtimeout": Binary(b"x")}), + ("date", {"w": 1, "wtimeout": datetime(2024, 1, 1, tzinfo=timezone.utc)}), + ("regex", {"w": 1, "wtimeout": Regex("x")}), + ("code", {"w": 1, "wtimeout": Code("function(){}")}), + ("timestamp", {"w": 1, "wtimeout": Timestamp(1, 1)}), + ("minKey", {"w": 1, "wtimeout": MinKey()}), + ("maxKey", {"w": 1, "wtimeout": MaxKey()}), + ("int32_max", {"w": 1, "wtimeout": INT32_MAX}), +] + +# Property [wtimeout Acceptance]: wtimeout accepts all BSON types. +WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_accepts_wtimeout_{val_name}", + docs=[{"_id": 1}], + command=lambda ctx, _wc=value, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + msg=f"{cmd} should accept wtimeout of type {val_name}.", + ) + for cmd in WRITE_COMMANDS + for val_name, value in _VALID_WTIMEOUT_VALUES +] + + +@pytest.mark.parametrize("test", pytest_params(WTIMEOUT_ACCEPTANCE_TESTS)) +def test_write_concern_wtimeout_accepted(collection, test: CommandTestCase): + """Test wtimeout sub-field accepts all BSON types.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertNotError(result, msg=test.msg) + + +_COMBINATION_VALUES = [ + ("w1_j_true", {"w": 1, "j": True}), + ("w1_j_false", {"w": 1, "j": False}), + ("majority_j_true", {"w": "majority", "j": True}), + ("majority_wtimeout", {"w": "majority", "wtimeout": 5_000}), + ("all_three", {"w": 1, "j": True, "wtimeout": 5_000}), +] + +# Property [Sub-Field Combinations]: w, j, and wtimeout work together correctly. +COMBINATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_wc_combination_{val_name}", + docs=[{"_id": 1}], + command=lambda ctx, _wc=value, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + msg=f"{cmd} should accept writeConcern combination {val_name}.", + ) + for cmd in WRITE_COMMANDS + for val_name, value in _COMBINATION_VALUES +] + + +@pytest.mark.parametrize("test", pytest_params(COMBINATION_TESTS)) +def test_write_concern_combinations(collection, test: CommandTestCase): + """Test writeConcern sub-field combinations are accepted.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertNotError(result, msg=test.msg) + + +_WTIMEOUT_EDGE_VALUES = [ + ("zero", {"w": 1, "wtimeout": 0}), + ("zero_with_majority", {"w": "majority", "wtimeout": 0}), + ("negative", {"w": 1, "wtimeout": -1}), + ("with_w0", {"w": 0, "wtimeout": 5_000}), +] + +# Property [wtimeout Edge Cases]: wtimeout zero/negative/with-w:0 are accepted without error. +WTIMEOUT_EDGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_wtimeout_edge_{val_name}", + docs=[{"_id": 1}], + command=lambda ctx, _wc=value, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + msg=f"{cmd} should accept wtimeout edge case {val_name}.", + ) + for cmd in WRITE_COMMANDS + for val_name, value in _WTIMEOUT_EDGE_VALUES +] + + +@pytest.mark.parametrize("test", pytest_params(WTIMEOUT_EDGE_TESTS)) +def test_write_concern_wtimeout_edge_cases(collection, test: CommandTestCase): + """Test wtimeout edge cases are accepted.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertNotError(result, msg=test.msg) + + +_PROVENANCE_VALUES = [ + ("clientSupplied", "clientSupplied"), + ("implicitDefault", "implicitDefault"), + ("customDefault", "customDefault"), + ("getLastErrorDefaults", "getLastErrorDefaults"), + ("null", None), +] + +# Property [Provenance Acceptance]: writeConcern accepts provenance sub-field. +PROVENANCE_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_accepts_provenance_{val_name}", + docs=[{"_id": 1}], + command=lambda ctx, _prov=value, _cmd=cmd: build_cmd( + _cmd, ctx, {"w": 1, "provenance": _prov} + ), + msg=f"{cmd} should accept provenance:'{value}'.", + ) + for cmd in WRITE_COMMANDS + for val_name, value in _PROVENANCE_VALUES +] + + +@pytest.mark.parametrize("test", pytest_params(PROVENANCE_ACCEPTANCE_TESTS)) +def test_write_concern_provenance_accepted(collection, test: CommandTestCase): + """Test writeConcern accepts provenance sub-field values.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertNotError(result, msg=test.msg) + + +def test_write_concern_null_equivalent_to_omitted(collection): + """Test writeConcern null produces same success as omitting writeConcern.""" + collection.insert_one({"_id": 1, "a": 0}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": None, + }, + ) + assertNotError(result, msg="update with writeConcern:null should not error.") + + +_WTIMEOUT_BOUNDARY_ACCEPTED_VALUES = [ + ("int32_min", {"w": 1, "wtimeout": INT32_MIN}), + ("negative_inf", {"w": 1, "wtimeout": FLOAT_NEGATIVE_INFINITY}), + ("decimal128_neg_inf", {"w": 1, "wtimeout": DECIMAL128_NEGATIVE_INFINITY}), +] + +# Property [wtimeout Boundary Acceptance]: wtimeout accepts INT32_MIN, -Infinity, and +# Decimal128 -Infinity — only values *exceeding* INT32_MAX are rejected. +WTIMEOUT_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_accepts_wtimeout_{val_name}", + docs=[{"_id": 1}], + command=lambda ctx, _wc=value, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + msg=f"{cmd} should accept wtimeout boundary value {val_name}.", + ) + for cmd in WRITE_COMMANDS + for val_name, value in _WTIMEOUT_BOUNDARY_ACCEPTED_VALUES +] + + +@pytest.mark.parametrize("test", pytest_params(WTIMEOUT_BOUNDARY_TESTS)) +def test_write_concern_wtimeout_boundary_accepted(collection, test: CommandTestCase): + """Test wtimeout accepts boundary values that fall at or below INT32_MAX.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertNotError(result, msg=test.msg) 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..d98e1dfbb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py @@ -0,0 +1,267 @@ +""" +writeConcern behavioral tests. + +Tests w:0 unacknowledged behavior, j:true overriding w:0, findAndModify return +semantics, and ordered/unordered interaction with writeConcern. +""" + +from documentdb_tests.framework.assertions import assertNotError, assertResult, assertSuccessPartial +from documentdb_tests.framework.error_codes import FAILED_TO_PARSE_ERROR +from documentdb_tests.framework.executor import execute_command + +# Property [w:0 Unacknowledged]: w:0 does not error and still performs the write. + + +def test_update_w0_does_not_error(collection): + """Test update with w:0 does not produce an error.""" + collection.insert_one({"_id": 1, "a": 0}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 99}}}], + "writeConcern": {"w": 0}, + }, + ) + assertNotError(result, msg="update with w:0 should not error.") + + +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_does_not_error(collection): + """Test delete with w:0 does not produce an error.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "delete": collection.name, + "deletes": [{"q": {"_id": 1}, "limit": 1}], + "writeConcern": {"w": 0}, + }, + ) + assertNotError(result, msg="delete with w:0 should not error.") + + +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.") + + +def test_findAndModify_w0_does_not_error(collection): + """Test findAndModify with w:0 does not produce an error.""" + collection.insert_one({"_id": 1, "a": 0}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "writeConcern": {"w": 0}, + }, + ) + assertNotError(result, msg="findAndModify with w:0 should not error.") + + +# Property [j:true Overrides w:0]: j:true with w:0 produces an acknowledged response. + + +def test_update_j_true_overrides_w0(collection): + """Test j:true overrides w:0 to produce acknowledgment on update.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0, "j": True}, + }, + ) + assertNotError(result, msg="update with j:true should override w:0.") + + +def test_delete_j_true_overrides_w0(collection): + """Test j:true overrides w:0 to produce acknowledgment on delete.""" + result = execute_command( + collection, + { + "delete": collection.name, + "deletes": [{"q": {"_id": 99}, "limit": 1}], + "writeConcern": {"w": 0, "j": True}, + }, + ) + assertNotError(result, msg="delete with j:true should override w:0.") + + +def test_findAndModify_j_true_overrides_w0(collection): + """Test j:true overrides w:0 to produce acknowledgment on findAndModify.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"a": 1}}, + "writeConcern": {"w": 0, "j": True}, + }, + ) + assertNotError(result, msg="findAndModify with j:true should override w:0.") + + +# Property [findAndModify Return Independence]: writeConcern does not affect return value. + + +def test_findAndModify_new_true_with_write_concern(collection): + """Test findAndModify new:true returns modified doc regardless of writeConcern.""" + collection.insert_one({"_id": 1, "a": 0}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": True, + "writeConcern": {"w": 1}, + }, + ) + assertSuccessPartial( + result, + {"value": {"_id": 1, "a": 99}}, + msg="findAndModify new:true should return modified doc.", + ) + + +def test_findAndModify_new_false_with_write_concern(collection): + """Test findAndModify new:false returns original doc regardless of writeConcern.""" + collection.insert_one({"_id": 1, "a": 0}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": False, + "writeConcern": {"w": 1}, + }, + ) + assertSuccessPartial( + result, + {"value": {"_id": 1, "a": 0}}, + msg="findAndModify new:false should return original doc.", + ) + + +def test_findAndModify_remove_with_write_concern(collection): + """Test findAndModify remove:true returns removed doc regardless of writeConcern.""" + collection.insert_one({"_id": 1, "a": 0}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "remove": True, + "writeConcern": {"w": 1}, + }, + ) + assertSuccessPartial( + result, + {"value": {"_id": 1, "a": 0}}, + msg="findAndModify remove:true should return removed doc.", + ) + + +# Property [Ordered Independence]: writeConcern works identically with ordered:true/false. + + +def test_update_write_concern_with_ordered_true(collection): + """Test update writeConcern works with ordered:true.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "ordered": True, + "writeConcern": {"w": 1}, + }, + ) + assertNotError(result, msg="update with ordered:true and writeConcern should succeed.") + + +def test_update_write_concern_with_ordered_false(collection): + """Test update writeConcern works with ordered:false.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "ordered": False, + "writeConcern": {"w": 1}, + }, + ) + assertNotError(result, msg="update with ordered:false and writeConcern should succeed.") + + +# Property [w:0 Error Suppression]: w:0 suppresses writeErrors that w:1 surfaces. + + +def test_update_w0_suppresses_write_errors(collection): + """Test w:0 suppresses writeErrors for an invalid operation.""" + collection.insert_one({"_id": 1, "a": 1}) + # multi:true with replacement doc is invalid, produces error with w:1. + result_w0 = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"a": 2}, "multi": True}], + "writeConcern": {"w": 0}, + }, + ) + # w:0 should not surface the writeErrors. + assertNotError(result_w0, msg="update with w:0 should suppress writeErrors.") + + +def test_update_w1_surfaces_write_errors(collection): + """Test w:1 surfaces writeErrors for the same invalid operation.""" + collection.insert_one({"_id": 1, "a": 1}) + result_w1 = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"a": 2}, "multi": True}], + "writeConcern": {"w": 1}, + }, + ) + # w:1 should return writeErrors. + assertResult( + result_w1, + error_code=FAILED_TO_PARSE_ERROR, + msg="update with w:1 should surface writeErrors.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_field_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_field_validation.py new file mode 100644 index 000000000..c72e8a764 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_field_validation.py @@ -0,0 +1,358 @@ +""" +writeConcern field validation tests. + +Tests type validation for the writeConcern field itself and its sub-fields +(w, j, wtimeout) across write commands (update, delete, findAndModify). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.query_and_write.write_concern.utils import ( + WRITE_COMMANDS, + build_cmd, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + 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, +) + +_NON_DOCUMENT_TYPES = [ + ("string", "majority"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", [1]), + ("objectId", ObjectId()), + ("date", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"x")), + ("regex", Regex("x")), + ("code", Code("f()")), + ("timestamp", Timestamp(0, 0)), + ("minKey", MinKey()), + ("maxKey", MaxKey()), +] + +# Property [Non-Document Rejection]: writeConcern field rejects non-document types. +NON_DOCUMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_rejects_non_document_wc_{type_name}", + command=lambda ctx, _wc=value, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + error_code=TYPE_MISMATCH_ERROR, + msg=f"{cmd} should reject writeConcern of type {type_name}.", + ) + for cmd in WRITE_COMMANDS + for type_name, value in _NON_DOCUMENT_TYPES +] + + +@pytest.mark.parametrize("test", pytest_params(NON_DOCUMENT_TESTS)) +def test_write_concern_rejects_non_document(collection, test: CommandTestCase): + """Test writeConcern rejects non-document types.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +_W_INVALID_TYPES = [ + ("bool", True), + ("array", [1]), + ("binary", Binary(b"x")), + ("objectId", ObjectId()), + ("date", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex("x")), + ("code", Code("f()")), + ("timestamp", Timestamp(0, 0)), + ("minKey", MinKey()), + ("maxKey", MaxKey()), +] + +# Property [w Type Rejection]: w rejects non-numeric non-string types. +W_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_w_rejects_{type_name}", + command=lambda ctx, _wc={"w": value}, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + error_code=FAILED_TO_PARSE_ERROR, + msg=f"{cmd} should reject w of type {type_name}.", + ) + for cmd in WRITE_COMMANDS + for type_name, value in _W_INVALID_TYPES +] + + +@pytest.mark.parametrize("test", pytest_params(W_TYPE_REJECTION_TESTS)) +def test_write_concern_w_rejects_invalid_type(collection, test: CommandTestCase): + """Test w sub-field rejects non-numeric non-string types.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +_W_INVALID_VALUES = [ + ("null", None, BAD_VALUE_ERROR), + ("negative", -1, FAILED_TO_PARSE_ERROR), + ("exceeds_50", 51, FAILED_TO_PARSE_ERROR), + ("float_nan", FLOAT_NAN, FAILED_TO_PARSE_ERROR), + ("float_neg_nan", FLOAT_NEGATIVE_NAN, FAILED_TO_PARSE_ERROR), + ("decimal128_nan", DECIMAL128_NAN, FAILED_TO_PARSE_ERROR), + ("decimal128_neg_nan", DECIMAL128_NEGATIVE_NAN, FAILED_TO_PARSE_ERROR), + ("float_inf", FLOAT_INFINITY, FAILED_TO_PARSE_ERROR), + ("float_neg_inf", FLOAT_NEGATIVE_INFINITY, FAILED_TO_PARSE_ERROR), + ("decimal128_inf", DECIMAL128_INFINITY, FAILED_TO_PARSE_ERROR), + ("decimal128_neg_inf", DECIMAL128_NEGATIVE_INFINITY, FAILED_TO_PARSE_ERROR), + ("tagged_non_numeric", {"dc1": "hello"}, FAILED_TO_PARSE_ERROR), + ("int64_max", INT64_MAX, FAILED_TO_PARSE_ERROR), + ("int64_min", INT64_MIN, FAILED_TO_PARSE_ERROR), +] + +# Property [w Value Rejection]: w rejects null, negatives, >50, NaN, and Infinity. +W_VALUE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_w_rejects_{val_name}", + command=lambda ctx, _wc={"w": value}, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + error_code=err, + msg=f"{cmd} should reject w value {val_name}.", + ) + for cmd in WRITE_COMMANDS + for val_name, value, err in _W_INVALID_VALUES +] + + +@pytest.mark.parametrize("test", pytest_params(W_VALUE_REJECTION_TESTS)) +def test_write_concern_w_rejects_invalid_value(collection, test: CommandTestCase): + """Test w sub-field rejects invalid values.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +_J_INVALID_TYPES = [ + ("string", "yes"), + ("array", [1]), + ("object", {"a": 1}), + ("binary", Binary(b"x")), + ("objectId", ObjectId()), + ("date", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex("x")), + ("code", Code("f()")), + ("timestamp", Timestamp(0, 0)), + ("minKey", MinKey()), + ("maxKey", MaxKey()), +] + +# Property [j Type Rejection]: j rejects non-boolean non-numeric types. +J_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_j_rejects_{type_name}", + command=lambda ctx, _wc={"w": 1, "j": value}, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + error_code=TYPE_MISMATCH_ERROR, + msg=f"{cmd} should reject j of type {type_name}.", + ) + for cmd in WRITE_COMMANDS + for type_name, value in _J_INVALID_TYPES +] + + +@pytest.mark.parametrize("test", pytest_params(J_TYPE_REJECTION_TESTS)) +def test_write_concern_j_rejects_invalid_type(collection, test: CommandTestCase): + """Test j sub-field rejects non-boolean non-numeric types.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +# Property [wtimeout Overflow Rejection]: wtimeout rejects Int64 exceeding INT32_MAX. +WTIMEOUT_OVERFLOW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_wtimeout_rejects_int64_overflow", + command=lambda ctx, _wc={"w": 1, "wtimeout": Int64(INT32_MAX + 1)}, _cmd=cmd: build_cmd( + _cmd, ctx, _wc + ), + error_code=FAILED_TO_PARSE_ERROR, + msg=f"{cmd} should reject wtimeout exceeding INT32_MAX.", + ) + for cmd in WRITE_COMMANDS +] + + +@pytest.mark.parametrize("test", pytest_params(WTIMEOUT_OVERFLOW_TESTS)) +def test_write_concern_wtimeout_overflow(collection, test: CommandTestCase): + """Test wtimeout rejects values exceeding INT32_MAX.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +# Property [Unknown Field Rejection]: unrecognized fields in writeConcern are rejected. +UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_rejects_unknown_wc_field", + command=lambda ctx, _wc={"w": 1, "unknownField": 1}, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg=f"{cmd} should reject unrecognized fields in writeConcern.", + ) + for cmd in WRITE_COMMANDS +] + + +@pytest.mark.parametrize("test", pytest_params(UNKNOWN_FIELD_TESTS)) +def test_write_concern_rejects_unknown_field(collection, test: CommandTestCase): + """Test writeConcern rejects unrecognized fields.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +# Property [w Case Sensitivity]: non-"majority" string w values are rejected on standalone. +W_CASE_SENSITIVITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_w_rejects_{case_name}", + command=lambda ctx, _wc={"w": value}, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + error_code=BAD_VALUE_ERROR, + msg=f"{cmd} should reject w:'{value}' (case-sensitive).", + ) + for cmd in WRITE_COMMANDS + for case_name, value in [("wrong_case_Majority", "Majority"), ("all_caps_MAJORITY", "MAJORITY")] +] + + +@pytest.mark.parametrize("test", pytest_params(W_CASE_SENSITIVITY_TESTS)) +def test_write_concern_w_case_sensitivity(collection, test: CommandTestCase): + """Test w field is case-sensitive for 'majority'.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +# Property [w Empty String]: empty string w is treated as custom tag, rejected on standalone. +W_EMPTY_STRING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_w_rejects_empty_string", + command=lambda ctx, _wc={"w": ""}, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + error_code=BAD_VALUE_ERROR, + msg=f"{cmd} should reject w:'' (empty string custom tag) on standalone.", + ) + for cmd in WRITE_COMMANDS +] + + +@pytest.mark.parametrize("test", pytest_params(W_EMPTY_STRING_TESTS)) +def test_write_concern_w_empty_string(collection, test: CommandTestCase): + """Test w empty string is rejected as custom tag on standalone.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +# Property [w Tagged Object Validation]: w as an object must be non-empty with numeric values only. +W_TAGGED_OBJECT_TESTS: list[CommandTestCase] = ( + [ + CommandTestCase( + f"{cmd}_w_tagged_rejects_empty_object", + command=lambda ctx, _cmd=cmd: build_cmd(_cmd, ctx, {"w": {}}), + error_code=FAILED_TO_PARSE_ERROR, + msg=f"{cmd} should reject empty object w (tagged write concern requires tags).", + ) + for cmd in WRITE_COMMANDS + ] + + [ + CommandTestCase( + f"{cmd}_w_tagged_rejects_string_value", + command=lambda ctx, _cmd=cmd: build_cmd(_cmd, ctx, {"w": {"dc1": "hello"}}), + error_code=FAILED_TO_PARSE_ERROR, + msg=f"{cmd} should reject tagged w with non-numeric tag value.", + ) + for cmd in WRITE_COMMANDS + ] + + [ + CommandTestCase( + f"{cmd}_w_tagged_rejects_nested_object", + command=lambda ctx, _cmd=cmd: build_cmd(_cmd, ctx, {"w": {"dc1": {"nested": 1}}}), + error_code=FAILED_TO_PARSE_ERROR, + msg=f"{cmd} should reject tagged w with nested object tag value.", + ) + for cmd in WRITE_COMMANDS + ] +) + + +@pytest.mark.parametrize("test", pytest_params(W_TAGGED_OBJECT_TESTS)) +def test_write_concern_w_tagged_object_validation(collection, test: CommandTestCase): + """Test w sub-field validates tagged write concern object structure.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +# Property [wtimeout Extended Overflow Rejection]: wtimeout rejects double, Decimal128, +# and +Infinity values exceeding INT32_MAX, in addition to Int64 overflow. +WTIMEOUT_EXTENDED_OVERFLOW_TESTS: list[CommandTestCase] = ( + [ + CommandTestCase( + f"{cmd}_wtimeout_rejects_double_overflow", + command=lambda ctx, _cmd=cmd: build_cmd( + _cmd, ctx, {"w": 1, "wtimeout": float(INT32_OVERFLOW)} + ), + error_code=FAILED_TO_PARSE_ERROR, + msg=f"{cmd} should reject double wtimeout exceeding INT32_MAX.", + ) + for cmd in WRITE_COMMANDS + ] + + [ + CommandTestCase( + f"{cmd}_wtimeout_rejects_decimal128_overflow", + command=lambda ctx, _cmd=cmd: build_cmd( + _cmd, ctx, {"w": 1, "wtimeout": Decimal128(str(INT32_OVERFLOW))} + ), + error_code=FAILED_TO_PARSE_ERROR, + msg=f"{cmd} should reject Decimal128 wtimeout exceeding INT32_MAX.", + ) + for cmd in WRITE_COMMANDS + ] + + [ + CommandTestCase( + f"{cmd}_wtimeout_rejects_float_infinity", + command=lambda ctx, _cmd=cmd: build_cmd( + _cmd, ctx, {"w": 1, "wtimeout": FLOAT_INFINITY} + ), + error_code=FAILED_TO_PARSE_ERROR, + msg=f"{cmd} should reject +Infinity wtimeout (exceeds INT32_MAX).", + ) + for cmd in WRITE_COMMANDS + ] +) + + +@pytest.mark.parametrize("test", pytest_params(WTIMEOUT_EXTENDED_OVERFLOW_TESTS)) +def test_write_concern_wtimeout_extended_overflow(collection, test: CommandTestCase): + """Test wtimeout rejects double, Decimal128, and +Infinity values exceeding INT32_MAX.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + 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..8283aaf2a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py @@ -0,0 +1,49 @@ +""" +writeConcern replica set tests. + +Tests writeConcern behaviors that require a replica set topology, such as +w values greater than 1. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.query_and_write.write_concern.utils import ( + WRITE_COMMANDS, + build_cmd, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertNotError +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.replica_set + +_W_REPLICA_VALUES = [ + ("w_50_max", {"w": 50}), + ("w_int64_50", {"w": Int64(50)}), +] + +# Property [w Large Values]: w up to 50 is accepted on a replica set. +W_REPLICA_SET_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_{val_name}", + docs=[{"_id": 1}], + command=lambda ctx, _wc=value, _cmd=cmd: build_cmd(_cmd, ctx, _wc), + msg=f"{cmd} should accept {val_name} on replica set.", + ) + for cmd in WRITE_COMMANDS + for val_name, value in _W_REPLICA_VALUES +] + + +@pytest.mark.parametrize("test", pytest_params(W_REPLICA_SET_TESTS)) +def test_write_concern_w_replica_set(collection, test: CommandTestCase): + """Test w values requiring replica set are accepted.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertNotError(result, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/utils/__init__.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/utils/__init__.py new file mode 100644 index 000000000..c0e2e4a3f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/utils/__init__.py @@ -0,0 +1,38 @@ +"""Shared helpers for writeConcern tests.""" + + +def build_cmd(cmd: str, ctx, wc) -> dict: + """Build a command dict with the given writeConcern for any write command.""" + if cmd == "update": + return { + "update": ctx.collection, + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": wc, + } + elif cmd == "delete": + return { + "delete": ctx.collection, + "deletes": [{"q": {"_id": 99}, "limit": 1}], + "writeConcern": wc, + } + elif cmd == "findAndModify": + return { + "findAndModify": ctx.collection, + "query": {"_id": 1}, + "update": {"$set": {"a": 1}}, + "writeConcern": wc, + } + elif cmd == "insert": + return { + "insert": ctx.collection, + "documents": [{"_id": 100}], + "writeConcern": wc, + } + raise ValueError(f"Unknown command: {cmd}") + + +# Commands to test exhaustively for writeConcern validation. +# insert excluded: already has dedicated tests at commands/insert/test_insert_write_concern.py. +# bulkWrite excluded: the MongoDB 8.0+ server-level bulkWrite command uses admin database +# with nsInfo/ops arrays, requiring fundamentally different command construction. +WRITE_COMMANDS = ["update", "delete", "findAndModify"]