diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/__init__.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_boolean_coercion.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_boolean_coercion.py new file mode 100644 index 000000000..b0cf820d7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_boolean_coercion.py @@ -0,0 +1,83 @@ +"""Tests for applyOps boolean coercion of allowAtomic and alwaysUpsert. + +Validates that allowAtomic accepts all BSON types (including null, +negative zero, NaN, Infinity) and that alwaysUpsert: false/null are +accepted (equivalent to default behavior). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.replica_set, pytest.mark.no_parallel] + + +# Property [allowAtomic Accepts All Types]: allowAtomic accepts any value +# without type rejection. All types are silently coerced or ignored. +ALLOWATOMIC_COERCION_SUCCESS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"allowatomic_{tid}", + command=lambda ctx, v=val: {"applyOps": [], "allowAtomic": v}, + expected={"ok": 1.0}, + msg=f"applyOps should accept allowAtomic: {tid}", + ) + for tid, val in [ + ("bool_true", True), + ("bool_false", False), + ("int32_one", 1), + ("int32_zero", 0), + ("int64_one", Int64(1)), + ("int64_zero", Int64(0)), + ("double_one", 1.0), + ("double_zero", 0.0), + ("decimal128_one", Decimal128("1")), + ("decimal128_zero", Decimal128("0")), + ("string", "true"), + ("array", []), + ("object", {}), + ("null", None), + ("neg_zero", -0.0), + ("nan", float("nan")), + ("infinity", float("inf")), + ] +] + +# Property [alwaysUpsert Accepted Values]: alwaysUpsert: false and null are +# accepted (equivalent to default behavior). +ALWAYSUPSERT_ACCEPTED_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "alwaysupsert_bool_false", + command=lambda ctx: {"applyOps": [], "alwaysUpsert": False}, + expected={"ok": 1.0}, + msg="applyOps should accept alwaysUpsert: false (equivalent to default)", + ), + ReplicationTestCase( + "alwaysupsert_null", + command=lambda ctx: {"applyOps": [], "alwaysUpsert": None}, + expected={"ok": 1.0}, + msg="applyOps should accept alwaysUpsert: null (treated as omitted)", + ), +] + +APPLYOPS_COERCION_SUCCESS_TESTS: list[ReplicationTestCase] = ( + ALLOWATOMIC_COERCION_SUCCESS_TESTS + ALWAYSUPSERT_ACCEPTED_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_COERCION_SUCCESS_TESTS)) +def test_applyOps_boolean_coercion(collection, test): + """Test applyOps boolean coercion success cases.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_core.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_core.py new file mode 100644 index 000000000..38b74be2f --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_core.py @@ -0,0 +1,494 @@ +"""Tests for applyOps command core behavior: operation types, response structure, +and basic functionality.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import CappedCollection, SiblingCollection + +pytestmark = [pytest.mark.replica_set, pytest.mark.no_parallel] + + +# Property [Insert Operations]: applyOps inserts documents into existing +# collections via the "i" op type. +APPLYOPS_INSERT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "insert_single_document", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert a single document", + ), + ReplicationTestCase( + "insert_multiple_documents", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "a": 1}}, + {"op": "i", "ns": ctx.namespace, "o": {"_id": 2, "a": 2}}, + {"op": "i", "ns": ctx.namespace, "o": {"_id": 3, "a": 3}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert multiple documents", + ), + ReplicationTestCase( + "insert_all_bson_types", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + { + "op": "i", + "ns": ctx.namespace, + "o": { + "_id": 1, + "int32": 42, + "int64": Int64(123_456_789), + "double": 3.14, + "decimal128": Decimal128("1.23"), + "string": "hello", + "bool": True, + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + "null": None, + "object": {"nested": "value"}, + "array": [1, 2, 3], + "binary": Binary(b"\x00\x01"), + "objectid": ObjectId(), + "regex": Regex("abc", "i"), + "timestamp": Timestamp(1, 1), + "minkey": MinKey(), + "maxkey": MaxKey(), + }, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert a document with all BSON types", + ), + ReplicationTestCase( + "insert_nested_document", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + { + "op": "i", + "ns": ctx.namespace, + "o": { + "_id": 1, + "a": {"b": {"c": {"d": 1}}}, + "arr": [[1, 2], [3, 4]], + "mixed": {"x": [{"y": 1}, {"y": 2}]}, + }, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert a nested document", + ), + ReplicationTestCase( + "insert_duplicate_id", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 2}}], + }, + expected={"ok": Eq(1.0), "applied": Eq(1)}, + msg="applyOps should succeed on duplicate _id insert", + ), + ReplicationTestCase( + "insert_into_capped_collection", + target_collection=CappedCollection(size=1_048_576), + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert into a capped collection", + ), +] + +# Property [Update Operations]: applyOps updates documents via the "u" op type. +APPLYOPS_UPDATE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "update_existing_document", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 2, "y": 3}, + "o2": {"_id": 1}, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should update an existing document", + ), + ReplicationTestCase( + "update_with_set_modifier", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"$v": 2, "diff": {"u": {"x": 2}}}, + "o2": {"_id": 1}, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should update with $v:2 diff format", + ), +] + +# Property [Delete Operations]: applyOps deletes documents via the "d" op type. +APPLYOPS_DELETE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "delete_existing_document", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [{"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should delete an existing document", + ), + ReplicationTestCase( + "delete_nonexistent_document", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "d", "ns": ctx.namespace, "o": {"_id": 999}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should succeed silently when deleting a non-existent document", + ), +] + +# Property [No-op Operations]: applyOps accepts no-op entries via the "n" op type. +APPLYOPS_NOOP_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "noop_operation", + command=lambda ctx: { + "applyOps": [{"op": "n", "ns": ctx.namespace, "o": {}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should accept no-op operation", + ), + ReplicationTestCase( + "noop_with_arbitrary_o", + command=lambda ctx: { + "applyOps": [ + {"op": "n", "ns": ctx.namespace, "o": {"msg": "test message"}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should accept no-op with arbitrary o document", + ), +] + +# Property [Command Operations]: applyOps executes DDL commands via the "c" op type. +APPLYOPS_COMMAND_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "command_create_collection", + docs=[], + command=lambda ctx: { + "applyOps": [ + { + "op": "c", + "ns": f"{ctx.database}.$cmd", + "o": {"create": f"{ctx.collection}_applyops_create"}, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should create a collection via command operation", + ), + ReplicationTestCase( + "command_drop_collection", + docs=[], + siblings=[SiblingCollection(suffix="_applyops_drop")], + command=lambda ctx: { + "applyOps": [ + { + "op": "c", + "ns": f"{ctx.database}.$cmd", + "o": {"drop": f"{ctx.collection}_applyops_drop"}, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should drop a collection via command operation", + ), +] + +# Property [Empty Array]: applyOps succeeds with an empty operations array. +APPLYOPS_EMPTY_ARRAY_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "empty_ops_array", + command=lambda ctx: {"applyOps": []}, + expected={"ok": Eq(1.0)}, + msg="applyOps should succeed with an empty operations array", + ), +] + +# Property [Unrecognized Field Acceptance]: unknown fields are silently ignored. +APPLYOPS_UNRECOGNIZED_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "unrecognized_single_field", + command=lambda ctx: {"applyOps": [], "unknownField": 1}, + expected={"ok": Eq(1.0)}, + msg="applyOps should ignore unrecognized fields", + ), + ReplicationTestCase( + "unrecognized_multiple_fields", + command=lambda ctx: {"applyOps": [], "foo": 1, "bar": "baz"}, + expected={"ok": Eq(1.0)}, + msg="applyOps should ignore multiple unrecognized fields", + ), + ReplicationTestCase( + "unrecognized_dollar_prefix", + command=lambda ctx: {"applyOps": [], "$unknown": 1}, + expected={"ok": Eq(1.0)}, + msg="applyOps should ignore dollar-prefixed unrecognized fields", + ), +] + +# Property [Response Structure]: applyOps returns ok, applied, and results +# fields reflecting the operations performed. +APPLYOPS_RESPONSE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "response_empty_ops", + command=lambda ctx: {"applyOps": []}, + expected={"ok": Eq(1.0), "applied": Eq(0), "results": Eq([])}, + msg="applyOps should return applied: 0 and results: [] for empty ops", + ), + ReplicationTestCase( + "response_single_op", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + }, + expected={"ok": Eq(1.0), "applied": Eq(1), "results": Eq([True])}, + msg="applyOps should return applied: 1 and results: [True] for single insert", + ), + ReplicationTestCase( + "response_multiple_ops", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "a": 1}}, + {"op": "i", "ns": ctx.namespace, "o": {"_id": 2, "a": 2}}, + ], + }, + expected={ + "ok": Eq(1.0), + "applied": Eq(2), + "results": Eq([True, True]), + }, + msg="applyOps should return applied: 2 and results: [True, True] for two inserts", + ), + ReplicationTestCase( + "response_noop_op", + command=lambda ctx: { + "applyOps": [{"op": "n", "ns": ctx.namespace, "o": {}}], + }, + expected={"ok": Eq(1.0), "applied": Eq(0)}, + msg="applyOps should not count no-op in applied", + ), +] + +APPLYOPS_CORE_TESTS: list[ReplicationTestCase] = ( + APPLYOPS_INSERT_TESTS + + APPLYOPS_UPDATE_TESTS + + APPLYOPS_DELETE_TESTS + + APPLYOPS_NOOP_TESTS + + APPLYOPS_COMMAND_TESTS + + APPLYOPS_EMPTY_ARRAY_TESTS + + APPLYOPS_UNRECOGNIZED_FIELD_TESTS + + APPLYOPS_RESPONSE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_CORE_TESTS)) +def test_applyOps_core(database_client, collection, test): + """Test applyOps command core behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Idempotent Operations]: applying the same operation twice succeeds. +APPLYOPS_IDEMPOTENT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "idempotent_delete", + docs=[{"_id": 1, "x": 1}], + setup=lambda coll: execute_admin_command( + coll, + {"applyOps": [{"op": "d", "ns": f"{coll.database.name}.{coll.name}", "o": {"_id": 1}}]}, + ), + command=lambda ctx: { + "applyOps": [{"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should succeed silently when deleting an already-deleted document", + ), + ReplicationTestCase( + "idempotent_insert", + docs=[{"_id": 0, "setup": True}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 1}, + } + ] + }, + ), + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 2}}], + }, + expected={"ok": Eq(1.0), "applied": Eq(1), "results": Eq([True])}, + msg="applyOps should succeed on duplicate _id insert (second apply)", + ), + ReplicationTestCase( + "idempotent_noop", + docs=[], + setup=lambda coll: execute_admin_command( + coll, + {"applyOps": [{"op": "n", "ns": f"{coll.database.name}.{coll.name}", "o": {}}]}, + ), + command=lambda ctx: { + "applyOps": [{"op": "n", "ns": ctx.namespace, "o": {}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should succeed when applying no-op twice", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_IDEMPOTENT_TESTS)) +def test_applyOps_idempotent(database_client, collection, test): + """Test applyOps idempotent operations.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Document Effect Verification]: applyOps operations produce the +# expected documents in the collection. +APPLYOPS_DOC_CHECK_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "doc_check_insert", + docs=[{"_id": 0, "setup": True}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 1}, + } + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[{"_id": 1, "x": 1}], + msg="applyOps insert should create the document", + ), + ReplicationTestCase( + "doc_check_update", + docs=[{"_id": 1, "x": 1}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "u", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 2, "y": 3}, + "o2": {"_id": 1}, + } + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[{"_id": 1, "x": 2, "y": 3}], + msg="applyOps update should modify the document", + ), + ReplicationTestCase( + "doc_check_delete", + docs=[{"_id": 1, "x": 1}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "d", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1}, + } + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[], + msg="applyOps delete should remove the document", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_DOC_CHECK_TESTS)) +def test_applyOps_doc_check(database_client, collection, test): + """Test applyOps operations produce the expected documents.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_entry_validation.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_entry_validation.py new file mode 100644 index 000000000..2b42bdd0c --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_entry_validation.py @@ -0,0 +1,238 @@ +"""Tests for applyOps operation entry validation.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + ILLEGAL_OPERATION_ERROR, + MISSING_FIELD_ERROR, + NAMESPACE_NOT_FOUND_ERROR, + NO_SUCH_KEY_ERROR, + TYPE_MISMATCH_ERROR, + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.replica_set, pytest.mark.no_parallel] + + +# Property [Missing Required Fields]: applyOps rejects entries that omit +# required fields (op, ns, o). +APPLYOPS_MISSING_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "missing_op", + command=lambda ctx: { + "applyOps": [{"ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=NO_SUCH_KEY_ERROR, + msg="applyOps should reject entry without op field", + ), + ReplicationTestCase( + "missing_ns", + command=lambda ctx: {"applyOps": [{"op": "i", "o": {"_id": 1}}]}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject entry without ns field", + ), + ReplicationTestCase( + "missing_o", + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace}], + }, + error_code=MISSING_FIELD_ERROR, + msg="applyOps should reject entry without o field", + ), + ReplicationTestCase( + "insert_empty_document_missing_id", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {}}], + }, + error_code=NO_SUCH_KEY_ERROR, + msg="applyOps should reject insert of document without _id", + ), +] + + +# Property [Invalid Op Type]: applyOps rejects entries with invalid op values. +APPLYOPS_INVALID_OP_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "op_invalid_char", + command=lambda ctx: { + "applyOps": [{"op": "x", "ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=BAD_VALUE_ERROR, + msg="applyOps should reject invalid op type", + ), + ReplicationTestCase( + "op_empty_string", + command=lambda ctx: { + "applyOps": [{"op": "", "ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject empty string op type", + ), + ReplicationTestCase( + "op_non_string", + command=lambda ctx: { + "applyOps": [{"op": 123, "ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject non-string op type", + ), + ReplicationTestCase( + "op_null", + command=lambda ctx: { + "applyOps": [{"op": None, "ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject null op type", + ), +] + + +# Property [Invalid Namespace]: applyOps rejects entries with invalid ns values. +APPLYOPS_INVALID_NS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "ns_empty_string", + command=lambda ctx: {"applyOps": [{"op": "i", "ns": "", "o": {"_id": 1}}]}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject empty string namespace", + ), + ReplicationTestCase( + "ns_non_string", + command=lambda ctx: {"applyOps": [{"op": "i", "ns": 123, "o": {"_id": 1}}]}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject non-string namespace", + ), + ReplicationTestCase( + "ns_null", + command=lambda ctx: {"applyOps": [{"op": "i", "ns": None, "o": {"_id": 1}}]}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject null namespace", + ), +] + + +# Property [Invalid Array Entries]: applyOps rejects non-object entries in +# the operations array. +APPLYOPS_INVALID_ENTRY_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "entry_non_object_int", + command=lambda ctx: {"applyOps": [1, 2, 3]}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject integer entries", + ), + ReplicationTestCase( + "entry_non_object_string", + command=lambda ctx: {"applyOps": ["insert"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject string entries", + ), + ReplicationTestCase( + "entry_null", + command=lambda ctx: {"applyOps": [None]}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject null entry", + ), + ReplicationTestCase( + "entry_empty_object", + command=lambda ctx: {"applyOps": [{}]}, + error_code=NO_SUCH_KEY_ERROR, + msg="applyOps should reject empty object entry", + ), +] + + +# Property [Nonexistent Collection]: applyOps rejects insert/update operations +# targeting a namespace that does not exist. +APPLYOPS_NONEXISTENT_NS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "insert_nonexistent_collection", + docs=[], + command=lambda ctx: { + "applyOps": [ + { + "op": "i", + "ns": f"{ctx.database}.{ctx.collection}_nonexistent", + "o": {"_id": 1, "x": 1}, + } + ], + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="applyOps should reject insert into non-existent collection", + ), +] + +# Property [o2 Field Validation]: o2 (update query document) must contain +# _id and must match an existing document. +APPLYOPS_O2_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "update_o2_unmatched_id", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 999, "x": 2}, + "o2": {"_id": 999}, + } + ], + }, + error_code=UNKNOWN_ERROR, + msg="applyOps should reject update when o2._id does not match any document", + ), + ReplicationTestCase( + "update_o2_missing_id", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 99}, + "o2": {}, + } + ], + }, + error_code=NO_SUCH_KEY_ERROR, + msg="applyOps should reject update when o2 is missing _id", + ), +] + +APPLYOPS_ENTRY_VALIDATION_TESTS: list[ReplicationTestCase] = ( + APPLYOPS_MISSING_FIELD_TESTS + + APPLYOPS_INVALID_OP_TESTS + + APPLYOPS_INVALID_NS_TESTS + + APPLYOPS_INVALID_ENTRY_TESTS + + APPLYOPS_NONEXISTENT_NS_TESTS + + APPLYOPS_O2_FIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_ENTRY_VALIDATION_TESTS)) +def test_applyOps_entry_validation(database_client, collection, test): + """Test applyOps operation entry validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_field_type.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_field_type.py new file mode 100644 index 000000000..0fe31421b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_field_type.py @@ -0,0 +1,86 @@ +"""Tests for applyOps command field type rejection.""" + +from __future__ import annotations + +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.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.replica_set, pytest.mark.no_parallel] + + +# Property [Command Field Type Rejection]: the applyOps command field expects +# an array. All non-array BSON types are rejected. +APPLYOPS_FIELD_TYPE_ERROR_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"field_type_{tid}", + command=lambda ctx, v=val: {"applyOps": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"applyOps should reject {tid} as command field value", + ) + for tid, val in [ + ("int32_positive", 1), + ("int32_zero", 0), + ("int32_negative", -1), + ("int64", Int64(1)), + ("int64_max", Int64(9_223_372_036_854_775_807)), + ("double", 1.0), + ("double_zero", 0.0), + ("double_negative", -1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("nan", float("nan")), + ("infinity", float("inf")), + ("string", "test"), + ("string_empty", ""), + ("object_empty", {}), + ("object", {"key": "value"}), + ("binary", Binary(b"\x00\x01\x02")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*", "i")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + [ + # Null also produces a type mismatch error. + ReplicationTestCase( + "field_type_null", + command=lambda ctx: {"applyOps": None}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject null as command field value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_FIELD_TYPE_ERROR_TESTS)) +def test_applyOps_field_type(collection, test): + """Test applyOps command field type rejection.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_multi_ops.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_multi_ops.py new file mode 100644 index 000000000..ec0932686 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_multi_ops.py @@ -0,0 +1,331 @@ +"""Tests for applyOps multi-operation interactions and optional parameters. + +Validates multi-op batches, cross-namespace operations, and +allowAtomic behavior. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import SiblingCollection + +pytestmark = [pytest.mark.replica_set, pytest.mark.no_parallel] + + +# Property [Multi-Operation Batches]: applyOps applies multiple operations +# in a single batch, combining insert, update, delete, and no-op ops. +APPLYOPS_MULTI_OPS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "insert_then_update", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}, + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 2}, + "o2": {"_id": 1}, + }, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert then update in one batch", + ), + ReplicationTestCase( + "insert_then_delete", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}, + {"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert then delete in one batch", + ), + ReplicationTestCase( + "update_then_delete", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 2}, + "o2": {"_id": 1}, + }, + {"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should update then delete in one batch", + ), + ReplicationTestCase( + "multiple_inserts", + docs=[{"_id": -1, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": i, "x": i}} for i in range(5) + ], + }, + expected={"ok": Eq(1.0), "applied": Eq(5)}, + msg="applyOps should insert 5 documents", + ), + ReplicationTestCase( + "mixed_op_types", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}, + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 2}, + "o2": {"_id": 1}, + }, + {"op": "n", "ns": ctx.namespace, "o": {}}, + {"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should handle mixed operation types", + ), + ReplicationTestCase( + "cross_namespace", + docs=[], + siblings=[SiblingCollection(suffix="_ns1"), SiblingCollection(suffix="_ns2")], + command=lambda ctx: { + "applyOps": [ + { + "op": "i", + "ns": f"{ctx.database}.{ctx.collection}_ns1", + "o": {"_id": 1, "src": "coll1"}, + }, + { + "op": "i", + "ns": f"{ctx.database}.{ctx.collection}_ns2", + "o": {"_id": 1, "src": "coll2"}, + }, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert into multiple namespaces", + ), +] + +# Property [allowAtomic]: applyOps accepts the allowAtomic option with +# boolean values and defaults to true when omitted. +APPLYOPS_ALLOW_ATOMIC_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "allow_atomic_true", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + "allowAtomic": True, + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should accept allowAtomic: true", + ), + ReplicationTestCase( + "allow_atomic_false", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + "allowAtomic": False, + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should accept allowAtomic: false", + ), + ReplicationTestCase( + "allow_atomic_omitted", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should default allowAtomic to true", + ), +] + +# Property [allowAtomic Effectiveness]: allowAtomic: false allows partial +# commits when a later operation fails. +APPLYOPS_ATOMIC_EFFECTIVENESS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "atomic_false_partial_commit", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 2, "x": 2}}, + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 999, "x": 3}, + "o2": {"_id": 999}, + }, + ], + "allowAtomic": False, + }, + error_code=UNKNOWN_ERROR, + msg="applyOps with allowAtomic: false reports error when second op fails", + ), +] + +APPLYOPS_MULTI_OPS_AND_OPTIONS_TESTS: list[ReplicationTestCase] = ( + APPLYOPS_MULTI_OPS_TESTS + APPLYOPS_ALLOW_ATOMIC_TESTS + APPLYOPS_ATOMIC_EFFECTIVENESS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_MULTI_OPS_AND_OPTIONS_TESTS)) +def test_applyOps_multi_ops(database_client, collection, test): + """Test applyOps multi-operation interactions and optional parameters.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Multi-Op Document Effect Verification]: multi-operation batches +# and allowAtomic produce the expected documents in the collection. +APPLYOPS_MULTI_OPS_DOC_CHECK_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "doc_check_insert_then_update", + docs=[{"_id": 0, "setup": True}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 1}, + }, + { + "op": "u", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 2}, + "o2": {"_id": 1}, + }, + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[{"_id": 1, "x": 2}], + msg="applyOps should leave the updated document after insert-then-update", + ), + ReplicationTestCase( + "doc_check_insert_then_delete", + docs=[{"_id": 0, "setup": True}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 1}, + }, + { + "op": "d", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1}, + }, + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[], + msg="applyOps should leave no document after insert-then-delete", + ), + ReplicationTestCase( + "doc_check_atomic_false_partial_commit", + docs=[{"_id": 1, "x": 1}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 2, "x": 2}, + }, + { + "op": "u", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 999, "x": 3}, + "o2": {"_id": 999}, + }, + ], + "allowAtomic": False, + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 2}}, + use_admin=False, + expected=[{"_id": 2, "x": 2}], + msg="applyOps with allowAtomic: false should have committed the first insert", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_MULTI_OPS_DOC_CHECK_TESTS)) +def test_applyOps_multi_ops_doc_check(database_client, collection, test): + """Test applyOps multi-operation document effects.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + ) + + +def test_applyOps_cross_namespace_doc_check(database_client, collection): + """Test applyOps cross-namespace inserts create documents in both collections.""" + db = database_client + coll_name = collection.name + ns1 = f"{db.name}.{coll_name}_ns1" + ns2 = f"{db.name}.{coll_name}_ns2" + db.create_collection(f"{coll_name}_ns1") + db.create_collection(f"{coll_name}_ns2") + execute_admin_command( + collection, + { + "applyOps": [ + {"op": "i", "ns": ns1, "o": {"_id": 1, "src": "coll1"}}, + {"op": "i", "ns": ns2, "o": {"_id": 1, "src": "coll2"}}, + ] + }, + ) + coll1 = db[f"{coll_name}_ns1"] + result1 = execute_command(coll1, {"find": coll1.name, "filter": {"_id": 1}}) + assertSuccess( + result1, + [{"_id": 1, "src": "coll1"}], + msg="applyOps should create document in first namespace", + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_rejected_params.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_rejected_params.py new file mode 100644 index 000000000..338d513a0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_rejected_params.py @@ -0,0 +1,150 @@ +"""Tests for applyOps rejected and unsupported parameters. + +Validates that applyOps rejects prepare, partialTxn, count, +alwaysUpsert, and preCondition parameters. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + APPLYOPS_ALWAYS_UPSERT_NOT_SUPPORTED_ERROR, + APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR, + BAD_VALUE_ERROR, + PARTIAL_TRANSACTION_NOT_ALLOWED_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.replica_set, pytest.mark.no_parallel] + + +# Property [Rejected Parameters]: prepare, partialTxn, and count fields are +# explicitly rejected by the applyOps command. +APPLYOPS_REJECTED_PARAM_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "rejected_prepare_true", + command=lambda ctx: {"applyOps": [], "prepare": True}, + error_code=BAD_VALUE_ERROR, + msg="applyOps should reject prepare: true", + ), + ReplicationTestCase( + "rejected_prepare_false", + command=lambda ctx: {"applyOps": [], "prepare": False}, + error_code=BAD_VALUE_ERROR, + msg="applyOps should reject prepare: false", + ), + ReplicationTestCase( + "rejected_partial_txn_true", + command=lambda ctx: {"applyOps": [], "partialTxn": True}, + error_code=PARTIAL_TRANSACTION_NOT_ALLOWED_ERROR, + msg="applyOps should reject partialTxn: true", + ), + ReplicationTestCase( + "rejected_partial_txn_false", + command=lambda ctx: {"applyOps": [], "partialTxn": False}, + error_code=PARTIAL_TRANSACTION_NOT_ALLOWED_ERROR, + msg="applyOps should reject partialTxn: false", + ), + ReplicationTestCase( + "rejected_count_true", + command=lambda ctx: {"applyOps": [], "count": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject count: true", + ), + ReplicationTestCase( + "rejected_count_false", + command=lambda ctx: {"applyOps": [], "count": False}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject count: false", + ), +] + +# Property [alwaysUpsert Rejection]: alwaysUpsert is no longer supported. +# Boolean true triggers a specific error; non-bool types trigger TYPE_MISMATCH_ERROR. +APPLYOPS_ALWAYSUPSERT_ERROR_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "alwaysupsert_bool_true", + command=lambda ctx: {"applyOps": [], "alwaysUpsert": True}, + error_code=APPLYOPS_ALWAYS_UPSERT_NOT_SUPPORTED_ERROR, + msg="applyOps should reject alwaysUpsert: true (no longer supported)", + ), +] + [ + ReplicationTestCase( + f"alwaysupsert_{tid}", + command=lambda ctx, v=val: {"applyOps": [], "alwaysUpsert": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"applyOps should reject alwaysUpsert: {tid} (wrong type)", + ) + for tid, val in [ + ("int32_one", 1), + ("int32_zero", 0), + ("int64_one", Int64(1)), + ("int64_zero", Int64(0)), + ("double_one", 1.0), + ("double_zero", 0.0), + ("decimal128_one", Decimal128("1")), + ("decimal128_zero", Decimal128("0")), + ("string", "true"), + ("array", []), + ("object", {}), + ] +] + +# Property [preCondition Rejection]: applyOps rejects the preCondition option. +APPLYOPS_PRECONDITION_ERROR_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "precondition_rejected", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1}}], + "preCondition": [], + }, + error_code=APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR, + msg="applyOps should reject preCondition (no longer supported)", + ), + ReplicationTestCase( + "precondition_with_entries_rejected", + docs=[{"_id": 1, "x": 10}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 20}, + "o2": {"_id": 1}, + } + ], + "preCondition": [ + {"ns": ctx.namespace, "q": {"_id": 1}, "res": {"x": 10}}, + ], + }, + error_code=APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR, + msg="applyOps should reject preCondition with entries (no longer supported)", + ), +] + +APPLYOPS_ALL_REJECTED_TESTS: list[ReplicationTestCase] = ( + APPLYOPS_REJECTED_PARAM_TESTS + + APPLYOPS_ALWAYSUPSERT_ERROR_TESTS + + APPLYOPS_PRECONDITION_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_ALL_REJECTED_TESTS)) +def test_applyOps_rejected_params(database_client, collection, test): + """Test applyOps rejects unsupported parameters.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_smoke_applyOps.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_smoke_applyOps.py new file mode 100644 index 000000000..160491315 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_smoke_applyOps.py @@ -0,0 +1,35 @@ +""" +Smoke test for applyOps command. + +Tests basic applyOps command functionality by applying a single insert +oplog entry. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = [pytest.mark.smoke, pytest.mark.replica_set, pytest.mark.no_parallel] + + +def test_smoke_applyOps(collection): + """Test basic applyOps command behavior.""" + collection.insert_one({"_id": 0, "setup": True}) + + namespace = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "applyOps": [ + { + "op": "i", + "ns": namespace, + "o": {"_id": 1, "x": 1}, + } + ], + }, + ) + + expected = {"ok": 1.0} + assertSuccessPartial(result, expected, msg="Should support applyOps command") diff --git a/documentdb_tests/compatibility/tests/system/replication/utils/__init__.py b/documentdb_tests/compatibility/tests/system/replication/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py b/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py new file mode 100644 index 000000000..5b93302e2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py @@ -0,0 +1,25 @@ +"""Shared test case for replication command tests.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) + + +@dataclass(frozen=True) +class ReplicationTestCase(CommandTestCase): + """Test case for replication command tests. + + Extends CommandTestCase with a ``use_admin`` flag that controls + whether the command is executed against the admin database. + + Attributes: + use_admin: If True (the default), execute against the admin + database via ``execute_admin_command``. If False, execute + against the test database via ``execute_command``. + """ + + use_admin: bool = True diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 2375b9dcd..eba315ef3 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -6,6 +6,7 @@ BAD_VALUE_ERROR = 2 NO_SUCH_KEY_ERROR = 4 GRAPH_CONTAINS_CYCLE_ERROR = 5 +UNKNOWN_ERROR = 8 FAILED_TO_PARSE_ERROR = 9 UNAUTHORIZED_ERROR = 13 TYPE_MISMATCH_ERROR = 14 @@ -193,6 +194,7 @@ REGEX_MISSING_REGEX_ERROR = 31023 REGEX_UNKNOWN_FIELD_ERROR = 31024 KEY_FIELD_NULL_BYTE_ERROR = 31032 +PARTIAL_TRANSACTION_NOT_ALLOWED_ERROR = 31056 OUT_OF_RANGE_CONVERSION_ERROR = 31109 UNSET_EMPTY_ARRAY_ERROR = 31119 UNSET_ARRAY_ELEMENT_TYPE_ERROR = 31120 @@ -490,6 +492,8 @@ ENCRYPTED_FIELD_UNSUPPORTED_TYPE_ERROR = 6338406 ENCRYPTED_FIELD_VIEW_TIMESERIES_ERROR = 6346401 ENCRYPTED_FIELD_CAPPED_ERROR = 6367301 +APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR = 6711600 +APPLYOPS_ALWAYS_UPSERT_NOT_SUPPORTED_ERROR = 6711601 ENCRYPTED_FIELD_RANGE_MIN_MAX_ERROR = 6720005 ENCRYPTED_FIELD_RANGE_TYPE_ERROR = 6775201 ENCRYPTED_FIELD_RANGE_MIN_MAX_TYPE_ERROR = 7018200