diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_collection_types.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_collection_types.py new file mode 100644 index 00000000..d2d6324f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_collection_types.py @@ -0,0 +1,206 @@ +"""Tests for cloneCollectionAsCapped source and destination collection types.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + ILLEGAL_OPERATION_ERROR, + NAMESPACE_EXISTS_ERROR, + NAMESPACE_NOT_FOUND_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + SystemBucketsCollection, + TimeseriesCollection, + ViewCollection, +) + +# Property [Source Non-Existent]: a non-existent source collection +# produces NAMESPACE_NOT_FOUND_ERROR. +SOURCE_NON_EXISTENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "non_existent_collection", + docs=None, + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "dest", + "size": 100_000, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="Non-existent source should fail with namespace not found", + ), + CommandTestCase( + "namespace_exceeds_255_bytes", + command=lambda ctx: { + "cloneCollectionAsCapped": "x" * (255 - len(ctx.database) - 1 + 1), + "toCollection": "dest", + "size": 100_000, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="Source name exceeding 255-byte namespace limit should fail with namespace not found", + ), +] + +# Property [Destination Already Exists]: a destination collection that +# already exists produces NAMESPACE_EXISTS_ERROR. +DEST_ALREADY_EXISTS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "same_name_source_and_dest", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": ctx.collection, + "size": 100_000, + }, + error_code=NAMESPACE_EXISTS_ERROR, + msg="Same name for source and destination should fail with namespace exists", + ), +] + +# Property [Source Collection Type Rejection]: views, timeseries, and +# timeseries backing collections are rejected as source. +SOURCE_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_as_source", + target_collection=ViewCollection(), + docs=[], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_dest", + "size": 100_000, + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="A view as source should fail with command not supported on view", + ), + CommandTestCase( + "timeseries_as_source", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_dest", + "size": 100_000, + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="A timeseries collection as source should fail with command not supported on view", + ), + CommandTestCase( + "system_buckets_source", + target_collection=SystemBucketsCollection(), + docs=[], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_dest", + "size": 100_000, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="system.buckets.* as source should be rejected as timeseries backing collection", + ), +] + +# Property [Destination is a View]: a view as destination produces +# COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR. +DEST_IS_VIEW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "dest_is_view", + target_collection=ViewCollection(), + docs=[], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection.removesuffix("_view"), + "toCollection": ctx.collection, + "size": 100_000, + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="A view as destination should produce view error, not namespace exists", + ), +] + +# Property [Source Collection Types - Success Cases]: the command +# succeeds for various source collection types. +SOURCE_COLLECTION_TYPES_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_source", + docs=[], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_dest", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="Empty source should create an empty capped destination", + ), + CommandTestCase( + "capped_source", + target_collection=CappedCollection(size=100_000), + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_dest", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="Capped collection as source should succeed", + ), + CommandTestCase( + "clustered_source", + target_collection=ClusteredCollection(), + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_dest", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="Clustered collection as source should succeed", + ), +] + +# Property [System Buckets Destination Without Timeseries]: creating a +# capped collection named system.buckets.X when no timeseries +# collection X exists is rejected with ILLEGAL_OPERATION_ERROR. +SYSTEM_BUCKETS_DEST_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "system_buckets_dest_no_timeseries", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "system.buckets.nonexistent", + "size": 100_000, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="system.buckets.X as dest without timeseries X should fail", + ), +] + +COLLECTION_TYPES_TESTS: list[CommandTestCase] = ( + SOURCE_NON_EXISTENT_TESTS + + DEST_ALREADY_EXISTS_TESTS + + SOURCE_TYPE_REJECTION_TESTS + + DEST_IS_VIEW_TESTS + + SOURCE_COLLECTION_TYPES_SUCCESS_TESTS + + SYSTEM_BUCKETS_DEST_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLECTION_TYPES_TESTS)) +def test_clone_collection_as_capped_collection_types(database_client, collection, test): + """Test cloneCollectionAsCapped source and destination collection types.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_command_basics.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_command_basics.py new file mode 100644 index 00000000..327d0767 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_command_basics.py @@ -0,0 +1,99 @@ +"""Tests for cloneCollectionAsCapped command basic behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Response Format]: the command returns {'ok': 1.0} with no +# additional fields on success. +RESPONSE_FORMAT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "success_response", + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="Should return ok:1.0", + ), +] + +# Property [Unrecognized Fields]: unrecognized top-level command fields +# are silently ignored. +UNRECOGNIZED_FIELDS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_unknown_field", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "unknownField": 1, + }, + expected={"ok": 1.0}, + msg="Single unrecognized field should be silently ignored", + ), + CommandTestCase( + "multiple_unknown_fields", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "foo": "bar", + "baz": [1, 2, 3], + }, + expected={"ok": 1.0}, + msg="Multiple unrecognized fields should be silently ignored", + ), + CommandTestCase( + "unknown_field_with_null_value", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "unknownField": None, + }, + expected={"ok": 1.0}, + msg="Unrecognized field with null value should be silently ignored", + ), + CommandTestCase( + "unknown_field_with_object_value", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "unknownField": {"nested": "doc"}, + }, + expected={"ok": 1.0}, + msg="Unrecognized field with object value should be silently ignored", + ), +] + +COMMAND_BASICS_TESTS: list[CommandTestCase] = RESPONSE_FORMAT_TESTS + UNRECOGNIZED_FIELDS_TESTS + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COMMAND_BASICS_TESTS)) +def test_clone_collection_as_capped_command_basics(database_client, collection, test): + """Test cloneCollectionAsCapped command basic behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_comment.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_comment.py new file mode 100644 index 00000000..cec7eb07 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_comment.py @@ -0,0 +1,404 @@ +"""Tests for cloneCollectionAsCapped comment parameter acceptance.""" + +import functools +from datetime import datetime, timezone +from typing import Any + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + 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.test_constants import ( + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Comment Parameter Acceptance]: the comment field accepts +# any BSON type without error. +COMMENT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "string", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": "hello", + }, + expected={"ok": 1.0}, + msg="comment=string should succeed", + ), + CommandTestCase( + "int32", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": 42, + }, + expected={"ok": 1.0}, + msg="comment=int32 should succeed", + ), + CommandTestCase( + "int64", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": Int64(42), + }, + expected={"ok": 1.0}, + msg="comment=Int64 should succeed", + ), + CommandTestCase( + "double", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": 3.14, + }, + expected={"ok": 1.0}, + msg="comment=double should succeed", + ), + CommandTestCase( + "decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": Decimal128("99"), + }, + expected={"ok": 1.0}, + msg="comment=Decimal128 should succeed", + ), + CommandTestCase( + "bool_true", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": True, + }, + expected={"ok": 1.0}, + msg="comment=True should succeed", + ), + CommandTestCase( + "bool_false", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": False, + }, + expected={"ok": 1.0}, + msg="comment=False should succeed", + ), + CommandTestCase( + "null", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": None, + }, + expected={"ok": 1.0}, + msg="comment=null should succeed", + ), + CommandTestCase( + "array", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": [1, 2, 3], + }, + expected={"ok": 1.0}, + msg="comment=array should succeed", + ), + CommandTestCase( + "object", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": {"key": "value"}, + }, + expected={"ok": 1.0}, + msg="comment=object should succeed", + ), + CommandTestCase( + "objectid", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": ObjectId("000000000000000000000001"), + }, + expected={"ok": 1.0}, + msg="comment=ObjectId should succeed", + ), + CommandTestCase( + "datetime", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + expected={"ok": 1.0}, + msg="comment=datetime should succeed", + ), + CommandTestCase( + "timestamp", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": Timestamp(1, 1), + }, + expected={"ok": 1.0}, + msg="comment=Timestamp should succeed", + ), + CommandTestCase( + "binary", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": Binary(b"hello"), + }, + expected={"ok": 1.0}, + msg="comment=Binary should succeed", + ), + CommandTestCase( + "regex", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": Regex("abc", "i"), + }, + expected={"ok": 1.0}, + msg="comment=Regex should succeed", + ), + CommandTestCase( + "code", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": Code("function(){}"), + }, + expected={"ok": 1.0}, + msg="comment=Code should succeed", + ), + CommandTestCase( + "code_with_scope", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": Code("function(){}", {"x": 1}), + }, + expected={"ok": 1.0}, + msg="comment=CodeWithScope should succeed", + ), + CommandTestCase( + "minkey", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": MinKey(), + }, + expected={"ok": 1.0}, + msg="comment=MinKey should succeed", + ), + CommandTestCase( + "maxkey", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": MaxKey(), + }, + expected={"ok": 1.0}, + msg="comment=MaxKey should succeed", + ), + CommandTestCase( + "deeply_nested_object", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": functools.reduce( + lambda inner, _: {"n": inner}, + range(50), + dict[str, Any](), + ), + }, + expected={"ok": 1.0}, + msg="comment=deeply nested object should succeed", + ), + CommandTestCase( + "nested_array", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": [[1, [2, [3]]], [4, 5]], + }, + expected={"ok": 1.0}, + msg="comment=nested array should succeed", + ), + CommandTestCase( + "large_string", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": "x" * 10_000, + }, + expected={"ok": 1.0}, + msg="comment=10KB string should succeed", + ), + CommandTestCase( + "large_object", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": {f"k{i}": i for i in range(10_000)}, + }, + expected={"ok": 1.0}, + msg="comment=large object should succeed", + ), + CommandTestCase( + "nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": FLOAT_NAN, + }, + expected={"ok": 1.0}, + msg="comment=NaN should succeed", + ), + CommandTestCase( + "infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": FLOAT_INFINITY, + }, + expected={"ok": 1.0}, + msg="comment=Infinity should succeed", + ), + CommandTestCase( + "negative_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": FLOAT_NEGATIVE_INFINITY, + }, + expected={"ok": 1.0}, + msg="comment=-Infinity should succeed", + ), + CommandTestCase( + "negative_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": DOUBLE_NEGATIVE_ZERO, + }, + expected={"ok": 1.0}, + msg="comment=-0.0 should succeed", + ), + CommandTestCase( + "null_bytes_in_string", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "comment": "hello\x00world", + }, + expected={"ok": 1.0}, + msg="comment=string with null bytes should succeed", + ), + CommandTestCase( + "omitted", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="omitting comment field should succeed", + ), +] + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COMMENT_ACCEPTANCE_TESTS)) +def test_clone_collection_as_capped_comment(database_client, collection, test): + """Test cloneCollectionAsCapped comment parameter acceptance.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_field_types.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_field_types.py new file mode 100644 index 00000000..4023659c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_field_types.py @@ -0,0 +1,412 @@ +"""Tests for cloneCollectionAsCapped field type validation.""" + +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.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [toCollection Type Errors]: non-string BSON types and +# missing toCollection field produce TYPE_MISMATCH_ERROR. +TO_COLLECTION_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "int32", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": 42, + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="int32 toCollection should fail", + ), + CommandTestCase( + "int64", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": Int64(42), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Int64 toCollection should fail", + ), + CommandTestCase( + "double", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": 3.14, + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="double toCollection should fail", + ), + CommandTestCase( + "decimal128", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": Decimal128("1"), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Decimal128 toCollection should fail", + ), + CommandTestCase( + "bool", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": True, + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="bool toCollection should fail", + ), + CommandTestCase( + "null", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": None, + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="null toCollection should fail", + ), + CommandTestCase( + "array", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": [1, 2], + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="array toCollection should fail", + ), + CommandTestCase( + "object", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": {"a": 1}, + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="object toCollection should fail", + ), + CommandTestCase( + "objectid", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": ObjectId("000000000000000000000001"), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="ObjectId toCollection should fail", + ), + CommandTestCase( + "datetime", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": datetime(2024, 1, 1, tzinfo=timezone.utc), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="datetime toCollection should fail", + ), + CommandTestCase( + "timestamp", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": Timestamp(1, 1), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Timestamp toCollection should fail", + ), + CommandTestCase( + "binary", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": Binary(b"\x01"), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Binary toCollection should fail", + ), + CommandTestCase( + "regex", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": Regex("abc", "i"), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Regex toCollection should fail", + ), + CommandTestCase( + "code", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": Code("function(){}"), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Code toCollection should fail", + ), + CommandTestCase( + "code_with_scope", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": Code("function(){}", {"x": 1}), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Code with scope toCollection should fail", + ), + CommandTestCase( + "minkey", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": MinKey(), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="MinKey toCollection should fail", + ), + CommandTestCase( + "maxkey", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": MaxKey(), + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="MaxKey toCollection should fail", + ), + CommandTestCase( + "missing_field", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="missing toCollection field should fail", + ), + CommandTestCase( + "wrong_case", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "tocollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="wrong-case field name should be treated as missing", + ), +] + +# Property [Source Type Errors]: all non-string BSON types for the +# cloneCollectionAsCapped field produce TYPE_MISMATCH_ERROR. +SOURCE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "int32", + command=lambda ctx: { + "cloneCollectionAsCapped": 42, + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="int32 source should fail", + ), + CommandTestCase( + "int64", + command=lambda ctx: { + "cloneCollectionAsCapped": Int64(42), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Int64 source should fail", + ), + CommandTestCase( + "double", + command=lambda ctx: { + "cloneCollectionAsCapped": 3.14, + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="double source should fail", + ), + CommandTestCase( + "decimal128", + command=lambda ctx: { + "cloneCollectionAsCapped": Decimal128("1"), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Decimal128 source should fail", + ), + CommandTestCase( + "bool", + command=lambda ctx: { + "cloneCollectionAsCapped": True, + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="bool source should fail", + ), + CommandTestCase( + "null", + command=lambda ctx: { + "cloneCollectionAsCapped": None, + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="null source should fail", + ), + CommandTestCase( + "array", + command=lambda ctx: { + "cloneCollectionAsCapped": [1, 2], + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="array source should fail", + ), + CommandTestCase( + "object", + command=lambda ctx: { + "cloneCollectionAsCapped": {"a": 1}, + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="object source should fail", + ), + CommandTestCase( + "objectid", + command=lambda ctx: { + "cloneCollectionAsCapped": ObjectId("000000000000000000000001"), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="ObjectId source should fail", + ), + CommandTestCase( + "datetime", + command=lambda ctx: { + "cloneCollectionAsCapped": datetime(2024, 1, 1, tzinfo=timezone.utc), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="datetime source should fail", + ), + CommandTestCase( + "timestamp", + command=lambda ctx: { + "cloneCollectionAsCapped": Timestamp(1, 1), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Timestamp source should fail", + ), + CommandTestCase( + "binary", + command=lambda ctx: { + "cloneCollectionAsCapped": Binary(b"\x01"), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Binary source should fail", + ), + CommandTestCase( + "regex", + command=lambda ctx: { + "cloneCollectionAsCapped": Regex("abc", "i"), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Regex source should fail", + ), + CommandTestCase( + "code", + command=lambda ctx: { + "cloneCollectionAsCapped": Code("function(){}"), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Code source should fail", + ), + CommandTestCase( + "code_with_scope", + command=lambda ctx: { + "cloneCollectionAsCapped": Code("function(){}", {"x": 1}), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="Code with scope source should fail", + ), + CommandTestCase( + "minkey", + command=lambda ctx: { + "cloneCollectionAsCapped": MinKey(), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="MinKey source should fail", + ), + CommandTestCase( + "maxkey", + command=lambda ctx: { + "cloneCollectionAsCapped": MaxKey(), + "toCollection": "dest", + "size": 100_000, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="MaxKey source should fail", + ), +] + +FIELD_TYPES_TESTS: list[CommandTestCase] = TO_COLLECTION_TYPE_ERROR_TESTS + SOURCE_TYPE_ERROR_TESTS + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(FIELD_TYPES_TESTS)) +def test_clone_collection_as_capped_field_types(database_client, collection, test): + """Test cloneCollectionAsCapped field type validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_namespace.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_namespace.py new file mode 100644 index 00000000..9767b7ce --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_namespace.py @@ -0,0 +1,436 @@ +"""Tests for cloneCollectionAsCapped namespace validation.""" + +from typing import Callable + +import pytest + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import INVALID_NAMESPACE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import NamedCollection + +# Property [Source Name Validation Errors]: invalid source names +# produce INVALID_NAMESPACE_ERROR. +SOURCE_NAME_VALIDATION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_string", + command=lambda ctx: { + "cloneCollectionAsCapped": "", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="empty string source should fail", + ), + CommandTestCase( + "null_byte_leading", + command=lambda ctx: { + "cloneCollectionAsCapped": "\x00coll", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="leading null byte should fail", + ), + CommandTestCase( + "null_byte_middle", + command=lambda ctx: { + "cloneCollectionAsCapped": "co\x00ll", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="null byte in middle should fail", + ), + CommandTestCase( + "null_byte_trailing", + command=lambda ctx: { + "cloneCollectionAsCapped": "coll\x00", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="trailing null byte should fail", + ), + CommandTestCase( + "leading_dot", + command=lambda ctx: { + "cloneCollectionAsCapped": ".coll", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="leading dot should fail", + ), + CommandTestCase( + "dollar_leading", + command=lambda ctx: { + "cloneCollectionAsCapped": "$coll", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="leading dollar should fail", + ), + CommandTestCase( + "dollar_middle", + command=lambda ctx: { + "cloneCollectionAsCapped": "co$ll", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="dollar in middle should fail", + ), + CommandTestCase( + "dollar_trailing", + command=lambda ctx: { + "cloneCollectionAsCapped": "coll$", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="trailing dollar should fail", + ), + CommandTestCase( + "dollar_multiple", + command=lambda ctx: { + "cloneCollectionAsCapped": "$co$ll$", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="multiple dollars should fail", + ), + CommandTestCase( + "dollar_double", + command=lambda ctx: { + "cloneCollectionAsCapped": "co$$ll", + "toCollection": "dest", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="double dollar should fail", + ), +] + +# Property [Destination Name Validation Errors]: invalid destination +# names produce INVALID_NAMESPACE_ERROR. +DEST_NAME_VALIDATION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_string", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="empty string dest should fail", + ), + CommandTestCase( + "null_byte_leading", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "\x00coll", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="leading null byte should fail", + ), + CommandTestCase( + "null_byte_middle", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "co\x00ll", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="null byte in middle should fail", + ), + CommandTestCase( + "null_byte_trailing", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "coll\x00", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="trailing null byte should fail", + ), + CommandTestCase( + "leading_dot", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": ".coll", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="leading dot should fail", + ), + CommandTestCase( + "dollar_leading", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "$coll", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="leading dollar should fail", + ), + CommandTestCase( + "dollar_middle", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "co$ll", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="dollar in middle should fail", + ), + CommandTestCase( + "dollar_trailing", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "coll$", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="trailing dollar should fail", + ), + CommandTestCase( + "system_other", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "system.other", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="system.other should fail", + ), + CommandTestCase( + "system_namespaces", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "system.namespaces", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="system.namespaces should fail", + ), + CommandTestCase( + "system_indexes", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "system.indexes", + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="system.indexes should fail", + ), + CommandTestCase( + "namespace_exceeds_255_bytes", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "x" * (255 - len(ctx.database) - 1 + 1), + "size": 100_000, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="namespace exceeding 255 bytes should fail", + ), +] + +# Property [Destination Name System Accepted]: certain system.* +# names are accepted as valid destination names. +DEST_NAME_SYSTEM_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "system_users", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "system.users", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="system.users should succeed", + ), + CommandTestCase( + "system_views", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "system.views", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="system.views should succeed", + ), + CommandTestCase( + "system_js", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "system.js", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="system.js should succeed", + ), + CommandTestCase( + "system_profile", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "system.profile", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="system.profile should succeed", + ), +] + +# Property [Collection Name Acceptance - Valid Patterns]: various +# special characters and Unicode patterns are accepted as valid +# collection names. +VALID_NAME_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"valid_name_{id}", + target_collection=NamedCollection(suffix=f"_{suffix}"), + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_dest", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg=f"{id.replace('_', ' ')} should be accepted as a valid name", + ) + for id, suffix in [ + ("single_char", "a"), + ("digits_only", "12345"), + ("space", " "), + ("tab", "\t"), + ("newline", "\n"), + ("carriage_return", "\r"), + ("cjk", "\u4e2d\u6587"), + ("emoji", "\U0001f389"), + ("non_leading_dot", "a.b"), + ("dash", "a-b"), + ("underscore", "a_b"), + ("backslash", "a\\b"), + ("forward_slash", "a/b"), + ("bom", "\ufeff"), + ("zwsp", "\u200b"), + ("zwj", "\u200d"), + ("ltr_mark", "\u200e"), + ("rtl_mark", "\u200f"), + ("nbsp", "\u00a0"), + ("en_space", "\u2000"), + ("em_space", "\u2003"), + ("brackets", "[test]"), + ("braces", "{test}"), + ("quotes", '"test"'), + ("fullwidth_dollar", "\uff04test"), + ("small_dollar", "\ufe69test"), + ] +] + + +def _suffix_to_byte_limit(char: str) -> Callable[[str, str], str]: + """Return a suffix lambda that fills the namespace to exactly 255 bytes.""" + char_bytes = len(char.encode()) + return lambda db, coll: char * ((255 - len(f"{db}.{coll}".encode())) // char_bytes) + + +# Property [Namespace Byte Limit]: the full namespace (db.collection) +# is limited to 255 bytes, counting by byte length not character count. +NAMESPACE_BYTE_LIMIT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ascii_at_limit", + target_collection=NamedCollection(suffix=_suffix_to_byte_limit("x")), + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "short_dest", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="1-byte ASCII chars at exactly 255 byte namespace should succeed", + ), + CommandTestCase( + "two_byte_at_limit", + target_collection=NamedCollection(suffix=_suffix_to_byte_limit("\u00e9")), + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "short_dest", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="2-byte UTF-8 chars at exactly 255 byte namespace should succeed", + ), + CommandTestCase( + "three_byte_at_limit", + target_collection=NamedCollection(suffix=_suffix_to_byte_limit("\u4e2d")), + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "short_dest", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="3-byte UTF-8 chars at exactly 255 byte namespace should succeed", + ), + CommandTestCase( + "four_byte_at_limit", + target_collection=NamedCollection(suffix=_suffix_to_byte_limit("\U0001f389")), + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": "short_dest", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="4-byte UTF-8 chars at exactly 255 byte namespace should succeed", + ), +] + +NAMESPACE_TESTS: list[CommandTestCase] = ( + SOURCE_NAME_VALIDATION_ERROR_TESTS + + DEST_NAME_VALIDATION_ERROR_TESTS + + DEST_NAME_SYSTEM_ACCEPTED_TESTS + + VALID_NAME_TESTS + + NAMESPACE_BYTE_LIMIT_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(NAMESPACE_TESTS)) +def test_clone_collection_as_capped_namespace(database_client, collection, test): + """Test cloneCollectionAsCapped namespace validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_size.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_size.py new file mode 100644 index 00000000..455b3fba --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_size.py @@ -0,0 +1,530 @@ +"""Tests for cloneCollectionAsCapped size parameter validation.""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_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_HALF, + DECIMAL128_INFINITY, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_HALF, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT64_MAX, +) + +# Property [Size Upper Bound Success]: the maximum accepted size is +# 1 PiB (1,125,899,906,842,624 bytes), inclusive. +SIZE_UPPER_BOUND_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "one_pib", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": Int64(1_125_899_906_842_624), + }, + expected={"ok": 1.0}, + msg="1 PiB should be accepted", + ), +] + +# Property [Size Upper Bound Error]: values exceeding 1 PiB or +# positive infinity produce BAD_VALUE_ERROR. +SIZE_BAD_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "one_over_pib", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": Int64(1_125_899_906_842_625), + }, + error_code=BAD_VALUE_ERROR, + msg="1 PiB + 1 should exceed limit", + ), + CommandTestCase( + "int64_max", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": INT64_MAX, + }, + error_code=BAD_VALUE_ERROR, + msg="Int64 max should exceed PiB limit", + ), + CommandTestCase( + "float_positive_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": FLOAT_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="float +Infinity should exceed PiB limit", + ), + CommandTestCase( + "decimal128_positive_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="Decimal128 +Infinity should exceed PiB limit", + ), + CommandTestCase( + "decimal128_large_exponent", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_LARGE_EXPONENT, + }, + error_code=BAD_VALUE_ERROR, + msg="Extreme large exponent should exceed PiB limit", + ), +] + +# Property [Size Type Errors]: non-numeric BSON types and missing +# size field produce INVALID_OPTIONS_ERROR. +SIZE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "string", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": "100", + }, + error_code=INVALID_OPTIONS_ERROR, + msg="string size should fail", + ), + CommandTestCase( + "bool", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": True, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="bool size should fail", + ), + CommandTestCase( + "null", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": None, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="null size should fail", + ), + CommandTestCase( + "array", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": [1, 2], + }, + error_code=INVALID_OPTIONS_ERROR, + msg="array size should fail", + ), + CommandTestCase( + "object", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": {"a": 1}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="object size should fail", + ), + CommandTestCase( + "objectid", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": ObjectId("000000000000000000000001"), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="ObjectId size should fail", + ), + CommandTestCase( + "datetime", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="datetime size should fail", + ), + CommandTestCase( + "timestamp", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": Timestamp(1, 1), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Timestamp size should fail", + ), + CommandTestCase( + "binary", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": Binary(b"\x01"), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Binary size should fail", + ), + CommandTestCase( + "regex", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": Regex("abc", "i"), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Regex size should fail", + ), + CommandTestCase( + "code", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": Code("function(){}"), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Code size should fail", + ), + CommandTestCase( + "code_with_scope", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": Code("function(){}", {"x": 1}), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Code with scope size should fail", + ), + CommandTestCase( + "minkey", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": MinKey(), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="MinKey size should fail", + ), + CommandTestCase( + "maxkey", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": MaxKey(), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="MaxKey size should fail", + ), + CommandTestCase( + "missing", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + }, + error_code=INVALID_OPTIONS_ERROR, + msg="missing size should fail", + ), +] + +# Property [Size Invalid Numeric Values]: zero, NaN, negative values, +# and fractional values that truncate or round to zero or negative +# produce INVALID_OPTIONS_ERROR. +SIZE_INVALID_NUMERIC_TESTS: list[CommandTestCase] = [ + # Zero values + CommandTestCase( + "int32_zero", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 0, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="int32 zero should fail", + ), + CommandTestCase( + "double_zero", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DOUBLE_ZERO, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="double 0.0 should fail", + ), + CommandTestCase( + "double_negative_zero", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DOUBLE_NEGATIVE_ZERO, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="double -0.0 should fail", + ), + CommandTestCase( + "decimal128_zero", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_ZERO, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128('0') should fail", + ), + CommandTestCase( + "decimal128_negative_zero", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_NEGATIVE_ZERO, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128('-0') should fail", + ), + # Negative integers + CommandTestCase( + "negative_int32", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": -1, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="negative int32 should fail with invalid options error", + ), + CommandTestCase( + "negative_int64", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": Int64(-100), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="negative Int64 should fail with invalid options error", + ), + # NaN and infinity + CommandTestCase( + "float_nan", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": FLOAT_NAN, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="float NaN should fail", + ), + CommandTestCase( + "decimal128_nan", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_NAN, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128 NaN should fail", + ), + CommandTestCase( + "float_negative_nan", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": FLOAT_NEGATIVE_NAN, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="float -NaN should fail", + ), + CommandTestCase( + "decimal128_negative_nan", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_NEGATIVE_NAN, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128 -NaN should fail", + ), + CommandTestCase( + "float_negative_infinity", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": FLOAT_NEGATIVE_INFINITY, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="float -Infinity should fail with invalid options error, not bad value", + ), + CommandTestCase( + "decimal128_negative_infinity", + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_NEGATIVE_INFINITY, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128 -Infinity should fail with invalid options error, not bad value", + ), + # Double truncation to zero or negative + CommandTestCase( + "double_fractional_below_one", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 0.5, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="0.5 truncates to 0 and should fail", + ), + CommandTestCase( + "double_negative_fractional_near_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": -0.5, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="-0.5 truncates to 0 and should fail", + ), + CommandTestCase( + "double_negative_1_5", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": -1.5, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="-1.5 should fail with INVALID_OPTIONS_ERROR", + ), + # Decimal128 rounding to zero or negative + CommandTestCase( + "decimal128_half_rounds_to_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_HALF, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="0.5 rounds to 0 and should fail", + ), + CommandTestCase( + "decimal128_just_below_half", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_JUST_BELOW_HALF, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="34-digit value at 0.5 boundary rounds to 0", + ), + CommandTestCase( + "decimal128_negative_half", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_NEGATIVE_HALF, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="-0.5 rounds to 0 and should fail", + ), + CommandTestCase( + "decimal128_negative_one_and_half", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_NEGATIVE_ONE_AND_HALF, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="-1.5 rounds to -2 and should fail", + ), + CommandTestCase( + "decimal128_small_exponent", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": DECIMAL128_SMALL_EXPONENT, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Extreme small exponent rounds to 0", + ), +] + +SIZE_TESTS: list[CommandTestCase] = ( + SIZE_UPPER_BOUND_SUCCESS_TESTS + + SIZE_BAD_VALUE_ERROR_TESTS + + SIZE_TYPE_ERROR_TESTS + + SIZE_INVALID_NUMERIC_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(SIZE_TESTS)) +def test_clone_collection_as_capped_size(database_client, collection, test): + """Test cloneCollectionAsCapped size parameter validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_durability.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_durability.py new file mode 100644 index 00000000..9f3b1f2c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_durability.py @@ -0,0 +1,183 @@ +"""Tests for cloneCollectionAsCapped writeConcern j and fsync validation.""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_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_ZERO + +# Property [WriteConcern j Acceptance]: j accepts bool, numeric, and +# null values. +WRITECONCERN_J_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"j_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"j": v}, + }, + expected={"ok": 1.0}, + msg=f"j={id} should succeed", + ) + for id, val in [ + ("bool_true", True), + ("bool_false", False), + ("int32", 0), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", DECIMAL128_ZERO), + ("null", None), + ] +] + +# Property [WriteConcern j Type Rejection]: j rejects non-coercible +# types with TYPE_MISMATCH_ERROR. +WRITECONCERN_J_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"j_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"j": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"j={id} should fail with type mismatch", + ) + for id, val in [ + ("string", "true"), + ("array", [1]), + ("object", {"a": 1}), + ("objectid", ObjectId("000000000000000000000001")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [WriteConcern fsync Acceptance]: fsync accepts bool, +# numeric, and null values. +WRITECONCERN_FSYNC_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"fsync_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"fsync": v}, + }, + expected={"ok": 1.0}, + msg=f"fsync={id} should succeed", + ) + for id, val in [ + ("bool_true", True), + ("bool_false", False), + ("int32", 0), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", DECIMAL128_ZERO), + ("null", None), + ] +] + +# Property [WriteConcern fsync Type Rejection]: fsync rejects +# non-coercible types with TYPE_MISMATCH_ERROR. +WRITECONCERN_FSYNC_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"fsync_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"fsync": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"fsync={id} should fail with type mismatch", + ) + for id, val in [ + ("string", "true"), + ("array", [1]), + ("object", {"a": 1}), + ("objectid", ObjectId("000000000000000000000001")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [WriteConcern fsync+j Conflict]: specifying both fsync:true +# and j:true together produces FAILED_TO_PARSE_ERROR. +WRITECONCERN_FSYNC_J_CONFLICT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "fsync_true_j_true", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"fsync": True, "j": True}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="fsync:true and j:true together should fail with failed to parse", + ), +] + +WC_DURABILITY_TESTS: list[CommandTestCase] = ( + WRITECONCERN_J_ACCEPTANCE_TESTS + + WRITECONCERN_J_TYPE_REJECTION_TESTS + + WRITECONCERN_FSYNC_ACCEPTANCE_TESTS + + WRITECONCERN_FSYNC_TYPE_REJECTION_TESTS + + WRITECONCERN_FSYNC_J_CONFLICT_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(WC_DURABILITY_TESTS)) +def test_clone_collection_as_capped_wc_durability(database_client, collection, test): + """Test cloneCollectionAsCapped writeConcern j and fsync validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_other_fields.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_other_fields.py new file mode 100644 index 00000000..6e7a950a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_other_fields.py @@ -0,0 +1,229 @@ +"""Tests for cloneCollectionAsCapped writeConcern other field validation.""" + +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.collections.commands.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 ( + FLOAT_INFINITY, + INT32_OVERFLOW, +) + +# Property [WriteConcern wtimeout Acceptance]: valid wtimeout values +# are accepted. +WRITECONCERN_WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"wtimeout": 0}, + }, + expected={"ok": 1.0}, + msg="wtimeout=0 should succeed", + ), + CommandTestCase( + "wtimeout_positive", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"wtimeout": 1000}, + }, + expected={"ok": 1.0}, + msg="wtimeout=1000 should succeed", + ), +] + +# Property [WriteConcern wtimeout Overflow]: wtimeout values exceeding +# int32 max or infinity produce FAILED_TO_PARSE_ERROR. +WRITECONCERN_WTIMEOUT_OVERFLOW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_over_int32_max", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"wtimeout": INT32_OVERFLOW}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="wtimeout > INT32_MAX should fail with failed to parse", + ), + CommandTestCase( + "wtimeout_positive_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"wtimeout": FLOAT_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="wtimeout=+Infinity should fail with failed to parse", + ), +] + +# Property [WriteConcern getLastError Acceptance]: the getLastError +# field accepts any BSON type without validation. +WRITECONCERN_GET_LAST_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"gle_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"getLastError": v}, + }, + expected={"ok": 1.0}, + msg=f"getLastError={id} should succeed", + ) + for id, val in [ + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("null", None), + ("string", "hello"), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId("000000000000000000000001")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [WriteConcern provenance Type Error]: provenance rejects +# non-string types with TYPE_MISMATCH_ERROR. +WRITECONCERN_PROVENANCE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "provenance_int", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"provenance": 42}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="provenance=int should fail with type mismatch", + ), +] + +# Property [WriteConcern provenance Invalid Enum]: provenance with an +# invalid enum string produces BAD_VALUE_ERROR. +WRITECONCERN_PROVENANCE_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "provenance_invalid_enum", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"provenance": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="provenance='invalid' should fail with bad value", + ), +] + +# Property [WriteConcern Unrecognized Fields]: unrecognized fields +# within writeConcern produce UNRECOGNIZED_COMMAND_FIELD_ERROR. +WRITECONCERN_UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"unknownField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Unrecognized field in writeConcern should fail", + ), + CommandTestCase( + "uppercase_w", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"W": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Case-sensitive: uppercase W should be unrecognized", + ), + CommandTestCase( + "leading_space_w", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {" w": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Whitespace-sensitive: ' w' should be unrecognized", + ), +] + +WC_OTHER_FIELDS_TESTS: list[CommandTestCase] = ( + WRITECONCERN_WTIMEOUT_ACCEPTANCE_TESTS + + WRITECONCERN_WTIMEOUT_OVERFLOW_TESTS + + WRITECONCERN_GET_LAST_ERROR_TESTS + + WRITECONCERN_PROVENANCE_TYPE_ERROR_TESTS + + WRITECONCERN_PROVENANCE_ENUM_ERROR_TESTS + + WRITECONCERN_UNRECOGNIZED_FIELD_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(WC_OTHER_FIELDS_TESTS)) +def test_clone_collection_as_capped_wc_other_fields(database_client, collection, test): + """Test cloneCollectionAsCapped writeConcern other field validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_toplevel.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_toplevel.py new file mode 100644 index 00000000..97fa79c3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_toplevel.py @@ -0,0 +1,120 @@ +"""Tests for cloneCollectionAsCapped writeConcern top-level validation.""" + +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.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [WriteConcern Top-Level Type Errors]: non-document BSON +# types for the writeConcern field produce TYPE_MISMATCH_ERROR. +WRITECONCERN_TOP_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"wc_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"{id} writeConcern should fail", + ) + for id, val in [ + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", [1, 2]), + ("string", "hello"), + ("objectid", ObjectId("000000000000000000000001")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [WriteConcern Top-Level Acceptance]: omitted, null, and +# empty document writeConcern are accepted. +WRITECONCERN_TOP_LEVEL_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wc_omitted", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="Omitting writeConcern should succeed", + ), + CommandTestCase( + "wc_empty_document", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {}, + }, + expected={"ok": 1.0}, + msg="Empty document writeConcern should be accepted", + ), + CommandTestCase( + "wc_null", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": None, + }, + expected={"ok": 1.0}, + msg="null writeConcern should be accepted", + ), +] + +WC_TOPLEVEL_TESTS: list[CommandTestCase] = ( + WRITECONCERN_TOP_LEVEL_TYPE_ERROR_TESTS + WRITECONCERN_TOP_LEVEL_ACCEPTANCE_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(WC_TOPLEVEL_TESTS)) +def test_clone_collection_as_capped_wc_toplevel(database_client, collection, test): + """Test cloneCollectionAsCapped writeConcern top-level validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_w.py b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_w.py new file mode 100644 index 00000000..3dd6fb25 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/cloneCollectionAsCapped/test_cloneCollectionAsCapped_wc_w.py @@ -0,0 +1,553 @@ +"""Tests for cloneCollectionAsCapped writeConcern w field validation.""" + +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.collections.commands.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, +) +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_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [WriteConcern w Acceptance]: w accepts 0, 1, "majority", +# and numeric types that coerce to valid values on standalone. +WRITECONCERN_W_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_0", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": 0}, + }, + expected={"ok": 1.0}, + msg="w=0 should succeed", + ), + CommandTestCase( + "w_1", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": 1}, + }, + expected={"ok": 1.0}, + msg="w=1 should succeed", + ), + CommandTestCase( + "w_majority", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": "majority"}, + }, + expected={"ok": 1.0}, + msg="w='majority' should succeed", + ), + CommandTestCase( + "w_double_truncation", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": 1.5}, + }, + expected={"ok": 1.0}, + msg="w=1.5 (double) should truncate to 1 and succeed", + ), + CommandTestCase( + "w_int64_1", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": Int64(1)}, + }, + expected={"ok": 1.0}, + msg="w=Int64(1) should succeed", + ), + CommandTestCase( + "w_decimal128_1", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": Decimal128("1")}, + }, + expected={"ok": 1.0}, + msg="w=Decimal128('1') should succeed", + ), + CommandTestCase( + "w_object_tag", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": {"dc1": 1}}, + }, + expected={"ok": 1.0}, + msg="w=object with numeric tag value should succeed", + ), +] + +# Property [WriteConcern w Type Rejection]: non-string, non-numeric, +# non-object types for w produce FAILED_TO_PARSE_ERROR. +WRITECONCERN_W_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_bool", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": True}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=bool should fail with failed to parse", + ), + CommandTestCase( + "w_array", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": [1, 2]}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=array should fail with failed to parse", + ), + CommandTestCase( + "w_null", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": None}, + }, + error_code=BAD_VALUE_ERROR, + msg="w=null should coerce to empty string and fail with bad value", + ), + CommandTestCase( + "w_objectid", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": ObjectId("000000000000000000000001")}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=ObjectId should fail with failed to parse", + ), + CommandTestCase( + "w_datetime", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=datetime should fail with failed to parse", + ), + CommandTestCase( + "w_timestamp", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": Timestamp(1, 1)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=Timestamp should fail with failed to parse", + ), + CommandTestCase( + "w_binary", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": Binary(b"\x01")}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=Binary should fail with failed to parse", + ), + CommandTestCase( + "w_regex", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": Regex("abc", "i")}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=Regex should fail with failed to parse", + ), + CommandTestCase( + "w_code", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": Code("function(){}")}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=Code should fail with failed to parse", + ), + CommandTestCase( + "w_minkey", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": MinKey()}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=MinKey should fail with failed to parse", + ), + CommandTestCase( + "w_maxkey", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": MaxKey()}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=MaxKey should fail with failed to parse", + ), +] + +# Property [WriteConcern w Numeric Range]: w values outside 0-50, +# NaN, and infinity produce FAILED_TO_PARSE_ERROR. +WRITECONCERN_W_NUMERIC_RANGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_negative", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": -1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=-1 should fail with failed to parse", + ), + CommandTestCase( + "w_over_50", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": 51}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=51 should fail with failed to parse", + ), + CommandTestCase( + "w_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": FLOAT_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=NaN should fail with failed to parse", + ), + CommandTestCase( + "w_positive_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": FLOAT_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=+Infinity should fail with failed to parse", + ), + CommandTestCase( + "w_negative_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": FLOAT_NEGATIVE_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=-Infinity should fail with failed to parse", + ), + CommandTestCase( + "w_decimal128_negative", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": Decimal128("-1")}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=Decimal128('-1') should fail with failed to parse", + ), + CommandTestCase( + "w_decimal128_over_50", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": Decimal128("51")}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=Decimal128('51') should fail with failed to parse", + ), + CommandTestCase( + "w_decimal128_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": Decimal128("NaN")}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=Decimal128 NaN should fail with failed to parse", + ), + CommandTestCase( + "w_decimal128_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": DECIMAL128_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=Decimal128 Infinity should fail with failed to parse", + ), + CommandTestCase( + "w_decimal128_negative_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": DECIMAL128_NEGATIVE_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=Decimal128 -Infinity should fail with failed to parse", + ), +] + +# Property [WriteConcern w Object Tag Rejection]: w as object rejects +# non-numeric tag values and empty objects with FAILED_TO_PARSE_ERROR. +WRITECONCERN_W_OBJECT_TAG_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_obj_empty", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": {}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=empty object should fail with failed to parse", + ), + CommandTestCase( + "w_obj_null_value", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": {"dc1": None}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=object with null tag value should fail", + ), + CommandTestCase( + "w_obj_bool_value", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": {"dc1": True}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=object with bool tag value should fail", + ), + CommandTestCase( + "w_obj_string_value", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": {"dc1": "hello"}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=object with string tag value should fail", + ), + CommandTestCase( + "w_obj_nested_object", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": {"dc1": {"nested": 1}}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=object with nested object tag value should fail", + ), + CommandTestCase( + "w_obj_array_value", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": {"dc1": [1]}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=object with array tag value should fail", + ), +] + +# Property [WriteConcern w Standalone Rejection]: w > 1 or unrecognized +# string values produce BAD_VALUE_ERROR on standalone. +WRITECONCERN_W_STANDALONE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_2_standalone", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": 2}, + }, + error_code=BAD_VALUE_ERROR, + msg="w=2 on standalone should fail with bad value", + ), + CommandTestCase( + "w_custom_string", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": "custom"}, + }, + error_code=BAD_VALUE_ERROR, + msg="w='custom' on standalone should fail with bad value", + ), + CommandTestCase( + "w_majority_case_sensitive", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": "Majority"}, + }, + error_code=BAD_VALUE_ERROR, + msg="w='Majority' (wrong case) on standalone should fail with bad value", + ), + CommandTestCase( + "w_decimal128_1_5", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": DECIMAL128_ONE_AND_HALF}, + }, + error_code=BAD_VALUE_ERROR, + msg="w=Decimal128('1.5') rounds to 2 and should fail with bad value on standalone", + ), + CommandTestCase( + "w_empty_string", + docs=[{"_id": 1}], + command=lambda ctx: { + "cloneCollectionAsCapped": ctx.collection, + "toCollection": f"{ctx.collection}_capped", + "size": 100_000, + "writeConcern": {"w": ""}, + }, + error_code=BAD_VALUE_ERROR, + msg="w=empty string should fail with bad value", + ), +] + +WC_W_TESTS: list[CommandTestCase] = ( + WRITECONCERN_W_ACCEPTANCE_TESTS + + WRITECONCERN_W_TYPE_REJECTION_TESTS + + WRITECONCERN_W_NUMERIC_RANGE_TESTS + + WRITECONCERN_W_OBJECT_TAG_TESTS + + WRITECONCERN_W_STANDALONE_REJECTION_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(WC_W_TESTS)) +def test_clone_collection_as_capped_wc_w(database_client, collection, test): + """Test cloneCollectionAsCapped writeConcern w field validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + 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/core/collections/commands/utils/command_test_case.py b/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py index 9e364c15..7dd59382 100644 --- a/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py +++ b/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py @@ -60,8 +60,8 @@ class CommandTestCase(BaseTestCase): target_collection: TargetCollection = field(default_factory=TargetCollection) indexes: list[IndexModel] | None = None docs: list[dict[str, Any]] | None = None - command: dict[str, Any] | Callable[[CommandContext], dict[str, Any]] | None = None - expected: dict[str, Any] | Callable[[CommandContext], dict[str, Any]] | None = None + command: dict[str, Any] | Callable[..., dict[str, Any]] | None = None + expected: dict[str, Any] | Callable[..., dict[str, Any]] | None = None def prepare(self, db: Database, collection: Collection) -> Collection: """Resolve the target collection and apply indexes/docs. diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 0fe94584..f2fcdb18 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -10,6 +10,7 @@ ILLEGAL_OPERATION_ERROR = 20 NAMESPACE_NOT_FOUND_ERROR = 26 INDEX_NOT_FOUND_ERROR = 27 +NAMESPACE_EXISTS_ERROR = 48 CANNOT_CREATE_INDEX_ERROR = 67 INDEX_ALREADY_EXISTS_ERROR = 68 INVALID_OPTIONS_ERROR = 72 diff --git a/documentdb_tests/framework/target_collection.py b/documentdb_tests/framework/target_collection.py index 6db5a3e3..d26434a0 100644 --- a/documentdb_tests/framework/target_collection.py +++ b/documentdb_tests/framework/target_collection.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Callable from pymongo.collection import Collection from pymongo.database import Database @@ -60,12 +60,17 @@ def resolve(self, db: Database, collection: Collection) -> Collection: @dataclass(frozen=True) class NamedCollection(TargetCollection): - """A collection with a custom name suffix.""" + """A collection with a custom name suffix. - suffix: str = "" + suffix may be a plain string or a callable (db_name, coll_name) -> str + for cases where the suffix depends on runtime values like namespace length. + """ + + suffix: str | Callable[[str, str], str] = "" def resolve(self, db: Database, collection: Collection) -> Collection: - name = f"{collection.name}{self.suffix}" + resolved = self.suffix(db.name, collection.name) if callable(self.suffix) else self.suffix + name = f"{collection.name}{resolved}" db.create_collection(name) return db[name] @@ -96,6 +101,16 @@ def resolve(self, db: Database, collection: Collection) -> Collection: return collection.database.client[self.db_name]["tmp"] +@dataclass(frozen=True) +class ClusteredCollection(TargetCollection): + """A user-created clustered collection.""" + + def resolve(self, db: Database, collection: Collection) -> Collection: + name = f"{collection.name}_clustered" + db.create_collection(name, clusteredIndex={"key": {"_id": 1}, "unique": True}) + return db[name] + + @dataclass(frozen=True) class TimeseriesCollection(TargetCollection): """A time series collection.""" @@ -114,3 +129,19 @@ def resolve(self, db: Database, collection: Collection) -> Collection: ts_opts["granularity"] = self.granularity db.create_collection(name, timeseries=ts_opts) return db[name] + + +@dataclass(frozen=True) +class SystemBucketsCollection(TimeseriesCollection): + """The system.buckets collection, populated by creating a timeseries collection.""" + + def resolve(self, db: Database, collection: Collection) -> Collection: + name = f"{collection.name}_ts" + ts_opts: dict[str, Any] = { + "timeField": self.time_field, + "metaField": self.meta_field, + } + if self.granularity is not None: + ts_opts["granularity"] = self.granularity + db.create_collection(name, timeseries=ts_opts) + return db[f"system.buckets.{name}"]