From da6b37b02105d5c867f3ddcc39e542f250f1ff28 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 7 May 2026 13:42:10 -0700 Subject: [PATCH 1/2] Add $convertToCapped command tests Signed-off-by: Daniel Frankcom --- .../test_convertToCapped_collection_types.py | 146 +++++ .../test_convertToCapped_command_fields.py | 149 +++++ .../test_convertToCapped_max_time_ms.py | 409 +++++++++++++ .../test_convertToCapped_namespace.py | 359 ++++++++++++ .../test_convertToCapped_size.py | 431 ++++++++++++++ .../test_convertToCapped_wc_durability.py | 178 ++++++ .../test_convertToCapped_wc_other_fields.py | 230 ++++++++ .../test_convertToCapped_wc_toplevel.py | 116 ++++ .../test_convertToCapped_wc_w.py | 538 ++++++++++++++++++ .../commands/utils/command_test_case.py | 4 +- .../framework/target_collection.py | 38 +- 11 files changed, 2593 insertions(+), 5 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_collection_types.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_command_fields.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_max_time_ms.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_namespace.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_size.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_durability.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_other_fields.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_toplevel.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_w.py diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_collection_types.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_collection_types.py new file mode 100644 index 00000000..46b49b6b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_collection_types.py @@ -0,0 +1,146 @@ +"""Tests for convertToCapped command - collection type handling.""" + +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, + 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, + TimeseriesCollection, + ViewCollection, +) + +# Property [Collection Type Success]: convertToCapped returns ok:1.0 on success for +# various collection states including empty, populated, and clustered. +COLLECTION_TYPE_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_collection", + docs=[], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Empty collection should return ok:1.0", + ), + CommandTestCase( + "populated_collection", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Populated collection should return ok:1.0", + ), + CommandTestCase( + "clustered_collection", + target_collection=ClusteredCollection(), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Clustered collection should return ok:1.0", + ), +] + +# Property [Data Truncation]: when the cap size is smaller than the existing +# data, the command still succeeds. +DATA_TRUNCATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "cap_smaller_than_data", + docs=[{"_id": i, "x": "a" * 100} for i in range(10)], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 256}, + expected={"ok": 1.0}, + msg="Cap smaller than existing data should succeed", + ), +] + +# Property [Already Capped]: converting an already-capped collection succeeds +# regardless of whether the new size is smaller, equal, or larger. +ALREADY_CAPPED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "smaller_size", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 2048}, + expected={"ok": 1.0}, + msg="Smaller size on already-capped collection should succeed", + ), + CommandTestCase( + "same_size", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Same size on already-capped collection should succeed", + ), + CommandTestCase( + "larger_size", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 8192}, + expected={"ok": 1.0}, + msg="Larger size on already-capped collection should succeed", + ), +] + +# Property [Collection Existence Errors]: attempting to convert a +# non-existent collection produces a namespace-not-found error. +EXISTENCE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "nonexistent_collection", + docs=None, + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="Non-existent collection should produce a namespace-not-found error", + ), +] + +# Property [Collection Type Errors]: views and timeseries collections +# produce a command-not-supported-on-view error. +COLLECTION_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view", + target_collection=ViewCollection(), + docs=None, + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="View should produce a command-not-supported-on-view error", + ), + CommandTestCase( + "timeseries", + target_collection=TimeseriesCollection(), + docs=None, + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="Timeseries collection should produce a command-not-supported-on-view error", + ), +] + +CONVERT_TO_CAPPED_COLLECTION_TYPE_TESTS: list[CommandTestCase] = ( + COLLECTION_TYPE_SUCCESS_TESTS + + DATA_TRUNCATION_TESTS + + ALREADY_CAPPED_TESTS + + EXISTENCE_ERROR_TESTS + + COLLECTION_TYPE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(CONVERT_TO_CAPPED_COLLECTION_TYPE_TESTS)) +def test_convert_to_capped_collection_types(database_client, collection, test): + """Test convertToCapped command behavior for various 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/convertToCapped/test_convertToCapped_command_fields.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_command_fields.py new file mode 100644 index 00000000..5edaf698 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_command_fields.py @@ -0,0 +1,149 @@ +"""Tests for convertToCapped command - ancillary command field handling.""" + +import datetime + +import pytest +from bson import ( + Binary, + Code, + DBRef, + 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 DECIMAL128_ONE_AND_HALF + +# Property [Comment Type Acceptance]: the comment field accepts any BSON +# type without affecting command success. +COMMENT_TYPE_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "convertToCapped": ctx.collection, + "size": 4096, + "comment": v, + }, + expected={"ok": 1.0}, + msg=f"comment={id} should be accepted", + ) + for id, val in [ + ("string", "hello"), + ("int32", 42), + ("int64", Int64(99)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("null", None), + ("array", [1, 2, 3]), + ("object", {"key": "value"}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"hello")), + ("regex", Regex("pattern", "i")), + ("code", Code("function() {}")), + ("code_with_scope", Code("function() {}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("datetime", datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)), + ("dbref", DBRef("coll", "id")), + ] +] + +# Property [Unknown Command Fields]: unrecognized top-level command fields +# are silently accepted without error. +UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_unknown_field", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "unknownField": "hello", + }, + expected={"ok": 1.0}, + msg="Single unknown field should be silently accepted", + ), + CommandTestCase( + "multiple_unknown_fields", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "foo": 42, + "bar": [1, 2, 3], + "baz": {"nested": True}, + }, + expected={"ok": 1.0}, + msg="Multiple unknown fields should be silently accepted", + ), +] + +# Property [bypassDocumentValidation Type Acceptance]: bypassDocumentValidation +# is accepted as a recognized command field with any BSON type without error. +BYPASS_DOC_VALIDATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bypass_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "convertToCapped": ctx.collection, + "size": 4096, + "bypassDocumentValidation": v, + }, + expected={"ok": 1.0}, + msg=f"bypassDocumentValidation={id} should be accepted", + ) + for id, val in [ + ("string", "hello"), + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("3.14")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, 2, 3]), + ("object", {"key": "value"}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex("pattern", "i")), + ("code", Code("function() {}")), + ("code_with_scope", Code("function() {}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +CONVERT_TO_CAPPED_COMMAND_FIELD_TESTS: list[CommandTestCase] = ( + COMMENT_TYPE_ACCEPTANCE_TESTS + UNKNOWN_FIELD_TESTS + BYPASS_DOC_VALIDATION_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(CONVERT_TO_CAPPED_COMMAND_FIELD_TESTS)) +def test_convert_to_capped_command_fields(database_client, collection, test): + """Test convertToCapped command ancillary field handling.""" + 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/convertToCapped/test_convertToCapped_max_time_ms.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_max_time_ms.py new file mode 100644 index 00000000..9dd88258 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_max_time_ms.py @@ -0,0 +1,409 @@ +"""Tests for convertToCapped command - maxTimeMS parameter validation.""" + +import datetime + +import pytest +from bson import ( + Binary, + Decimal128, + Int64, + 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, +) +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_NAN, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_NAN, + INT32_MAX, + INT32_OVERFLOW, +) + +# Property [maxTimeMS Success]: maxTimeMS is accepted with valid +# non-negative numeric values that represent integers within int32 range. +MAX_TIME_MS_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_time_ms_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": 0, + }, + expected={"ok": 1.0}, + msg="maxTimeMS=0 means no time limit and should succeed", + ), + CommandTestCase( + "max_time_ms_small_int", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": 1000, + }, + expected={"ok": 1.0}, + msg="maxTimeMS=1000 (int32) should succeed", + ), + CommandTestCase( + "max_time_ms_int32_max", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": INT32_MAX, + }, + expected={"ok": 1.0}, + msg="maxTimeMS at the int32 maximum should succeed", + ), + CommandTestCase( + "max_time_ms_double_whole", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": 1000.0, + }, + expected={"ok": 1.0}, + msg="maxTimeMS as whole-number double should succeed", + ), + CommandTestCase( + "max_time_ms_int64", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": Int64(INT32_MAX), + }, + expected={"ok": 1.0}, + msg="maxTimeMS as Int64 at int32 max should succeed", + ), + CommandTestCase( + "max_time_ms_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": Decimal128("1000"), + }, + expected={"ok": 1.0}, + msg="maxTimeMS as Decimal128 should succeed", + ), + CommandTestCase( + "max_time_ms_double_negative_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DOUBLE_NEGATIVE_ZERO, + }, + expected={"ok": 1.0}, + msg="maxTimeMS as double -0.0 should be treated as 0", + ), + CommandTestCase( + "max_time_ms_decimal128_negative_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DECIMAL128_NEGATIVE_ZERO, + }, + expected={"ok": 1.0}, + msg="maxTimeMS as Decimal128 -0 should be treated as 0", + ), + CommandTestCase( + "max_time_ms_double_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DOUBLE_ZERO, + }, + expected={"ok": 1.0}, + msg="maxTimeMS as double 0.0 should succeed", + ), + CommandTestCase( + "max_time_ms_decimal128_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DECIMAL128_ZERO, + }, + expected={"ok": 1.0}, + msg="maxTimeMS as Decimal128 0 should succeed", + ), + CommandTestCase( + "max_time_ms_null", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": None, + }, + expected={"ok": 1.0}, + msg="maxTimeMS=null should be treated as absent and succeed", + ), +] + +# Property [maxTimeMS Type Errors]: non-numeric, non-null types produce a +# type-mismatch error. +MAX_TIME_MS_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"max_time_ms_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"maxTimeMS={id} should produce a type-mismatch error", + ) + for id, val in [ + ("string", "hello"), + ("bool", True), + ("array", [1]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"x")), + ("regex", Regex("a", "i")), + ("timestamp", Timestamp(1, 1)), + ("datetime", datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)), + ] +] + +# Property [maxTimeMS Value Errors]: negative values and values exceeding +# INT32_MAX produce a bad-value error; non-integer numeric values produce +# a failed-to-parse error. +MAX_TIME_MS_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_time_ms_negative_int", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": -1, + }, + error_code=BAD_VALUE_ERROR, + msg="Negative int maxTimeMS should produce a bad-value error", + ), + CommandTestCase( + "max_time_ms_negative_double", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": -1.0, + }, + error_code=BAD_VALUE_ERROR, + msg="Negative double maxTimeMS should produce a bad-value error", + ), + CommandTestCase( + "max_time_ms_negative_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": Decimal128("-1"), + }, + error_code=BAD_VALUE_ERROR, + msg="Negative Decimal128 maxTimeMS should produce a bad-value error", + ), + CommandTestCase( + "max_time_ms_exceeds_int32_max_int64", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": Int64(INT32_MAX + 1), + }, + error_code=BAD_VALUE_ERROR, + msg="Int64 exceeding INT32_MAX should produce a bad-value error", + ), + CommandTestCase( + "max_time_ms_exceeds_int32_max_double", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": float(INT32_OVERFLOW), + }, + error_code=BAD_VALUE_ERROR, + msg="Double exceeding INT32_MAX should produce a bad-value error", + ), + CommandTestCase( + "max_time_ms_exceeds_int32_max_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": Decimal128(str(INT32_OVERFLOW)), + }, + error_code=BAD_VALUE_ERROR, + msg="Decimal128 exceeding INT32_MAX should produce a bad-value error", + ), + CommandTestCase( + "max_time_ms_fractional_double", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": 1.5, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Fractional double maxTimeMS should produce a failed-to-parse error", + ), + CommandTestCase( + "max_time_ms_fractional_double_half", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": 0.5, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Double 0.5 maxTimeMS should produce a failed-to-parse error (no truncation)", + ), + CommandTestCase( + "max_time_ms_fractional_decimal128_half", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DECIMAL128_HALF, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Decimal128 0.5 maxTimeMS should produce a failed-to-parse error (no rounding)", + ), + CommandTestCase( + "max_time_ms_fractional_decimal128_one_and_half", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DECIMAL128_ONE_AND_HALF, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Decimal128 1.5 maxTimeMS should produce a failed-to-parse error (no rounding)", + ), + CommandTestCase( + "max_time_ms_fractional_decimal128_two_and_half", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DECIMAL128_TWO_AND_HALF, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Decimal128 2.5 maxTimeMS should produce a failed-to-parse error (no rounding)", + ), + CommandTestCase( + "max_time_ms_double_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": FLOAT_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Double infinity maxTimeMS should produce a failed-to-parse error", + ), + CommandTestCase( + "max_time_ms_double_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": FLOAT_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Double NaN maxTimeMS should produce a failed-to-parse error", + ), + CommandTestCase( + "max_time_ms_decimal128_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DECIMAL128_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Decimal128 infinity maxTimeMS should produce a failed-to-parse error", + ), + CommandTestCase( + "max_time_ms_decimal128_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DECIMAL128_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Decimal128 NaN maxTimeMS should produce a failed-to-parse error", + ), + CommandTestCase( + "max_time_ms_double_negative_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": FLOAT_NEGATIVE_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Double -NaN maxTimeMS should produce a failed-to-parse error", + ), + CommandTestCase( + "max_time_ms_decimal128_negative_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "maxTimeMS": DECIMAL128_NEGATIVE_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Decimal128 -NaN maxTimeMS should produce a failed-to-parse error", + ), +] + +CONVERT_TO_CAPPED_MAX_TIME_MS_TESTS: list[CommandTestCase] = ( + MAX_TIME_MS_SUCCESS_TESTS + MAX_TIME_MS_TYPE_ERROR_TESTS + MAX_TIME_MS_VALUE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(CONVERT_TO_CAPPED_MAX_TIME_MS_TESTS)) +def test_convert_to_capped_max_time_ms(database_client, collection, test): + """Test convertToCapped command maxTimeMS 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/convertToCapped/test_convertToCapped_namespace.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_namespace.py new file mode 100644 index 00000000..179cb529 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_namespace.py @@ -0,0 +1,359 @@ +"""Tests for convertToCapped command - namespace and collection name validation.""" + +import datetime + +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, + ILLEGAL_OPERATION_ERROR, + INVALID_NAMESPACE_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 ( + NamedCollection, + SystemBucketsCollection, + SystemViewsCollection, + TargetDatabase, +) + +# Property [Collection Name Max Length Success]: the maximum accepted name +# length (db_name_len + col_name_len + 26 <= 255) succeeds. +NAME_MAX_LENGTH_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_at_max_length", + target_collection=NamedCollection( + suffix=lambda db_name, coll_name: "x" + * (255 - len(db_name.encode("utf-8")) - 26 - len(coll_name)) + ), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Name at maximum byte length should be accepted", + ), +] + +# Property [Collection Name Valid Characters]: convertToCapped accepts +# collection names containing special and non-ASCII characters. +NAME_VALID_CHARS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hyphen", + target_collection=NamedCollection(suffix="-name"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Hyphenated name should be accepted", + ), + CommandTestCase( + "space", + target_collection=NamedCollection(suffix=" space"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Space in name should be accepted", + ), + CommandTestCase( + "non_leading_dot", + target_collection=NamedCollection(suffix=".dotted"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Non-leading dot in name should be accepted", + ), + CommandTestCase( + "backslash", + target_collection=NamedCollection(suffix="\\x"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Backslash in name should be accepted", + ), + CommandTestCase( + "braces", + target_collection=NamedCollection(suffix="{x}"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Braces in name should be accepted", + ), + CommandTestCase( + "brackets", + target_collection=NamedCollection(suffix="[x]"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Brackets in name should be accepted", + ), + CommandTestCase( + "at_sign", + target_collection=NamedCollection(suffix="@x"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="At sign in name should be accepted", + ), + CommandTestCase( + "hash", + target_collection=NamedCollection(suffix="#x"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Hash in name should be accepted", + ), + CommandTestCase( + "percent", + target_collection=NamedCollection(suffix="%x"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Percent in name should be accepted", + ), + CommandTestCase( + "unicode_2byte", + target_collection=NamedCollection(suffix="\u00e9"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="2-byte unicode in name should be accepted", + ), + CommandTestCase( + "unicode_3byte", + target_collection=NamedCollection(suffix="\u4e16"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="3-byte unicode in name should be accepted", + ), + CommandTestCase( + "unicode_4byte", + target_collection=NamedCollection(suffix="\U0001f600"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="4-byte unicode in name should be accepted", + ), + CommandTestCase( + "control_x01", + target_collection=NamedCollection(suffix="\x01"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Control char 0x01 in name should be accepted", + ), + CommandTestCase( + "tab", + target_collection=NamedCollection(suffix="\t"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Tab character in name should be accepted", + ), + CommandTestCase( + "newline", + target_collection=NamedCollection(suffix="\n"), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Newline character in name should be accepted", + ), +] + +# Property [Collection Name Type Errors]: all non-string types for the +# convertToCapped field produce an invalid namespace error. +NAME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"name_{id}", + docs=None, + command=lambda ctx, v=val: {"convertToCapped": v, "size": 4096}, + error_code=INVALID_NAMESPACE_ERROR, + msg=f"{id} name should produce an invalid namespace error", + ) + for id, val in [ + ("int32", 123), + ("int64", Int64(123)), + ("double", 3.14), + ("decimal128", Decimal128("3.14")), + ("bool", True), + ("null", None), + ("array", ["a", "b"]), + ("object", {"key": "val"}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex("pat", "i")), + ("code", Code("function() {}")), + ("code_with_scope", Code("function() {}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("expression_object", {"$concat": ["a", "b"]}), + ] +] + +# Property [Collection Name Value Errors]: invalid string values for the +# collection name (empty, null byte, leading dot) produce an invalid +# namespace error. +NAME_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_empty_string", + docs=None, + command=lambda ctx: {"convertToCapped": "", "size": 4096}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Empty string name should produce an invalid namespace error", + ), + CommandTestCase( + "name_null_byte", + docs=None, + command=lambda ctx: {"convertToCapped": "test\x00name", "size": 4096}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Name with null byte should produce an invalid namespace error", + ), + CommandTestCase( + "name_leading_dot", + docs=None, + command=lambda ctx: {"convertToCapped": ".leadingdot", "size": 4096}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Name starting with a dot should produce an invalid namespace error", + ), +] + +# Property [Collection Name Dollar Prefix]: dollar-sign prefixed strings +# are treated as literal collection names and produce a namespace-not-found +# error when the collection does not exist. +NAME_DOLLAR_PREFIX_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "dollar", + docs=None, + command=lambda ctx: {"convertToCapped": "$", "size": 4096}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="Dollar-sign name should be treated as literal, not field path", + ), + CommandTestCase( + "double_dollar", + docs=None, + command=lambda ctx: {"convertToCapped": "$$", "size": 4096}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="Double dollar name should be treated as literal, not variable", + ), + CommandTestCase( + "dollar_test", + docs=None, + command=lambda ctx: {"convertToCapped": "$test", "size": 4096}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="Dollar-prefixed name should be treated as literal collection name", + ), + CommandTestCase( + "dollar_cmd", + docs=None, + command=lambda ctx: {"convertToCapped": "$cmd", "size": 4096}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="$cmd should be treated as literal name, not special command namespace", + ), +] + +# Property [System Namespace Errors]: system.buckets.* produces an +# illegal-operation error; system.views produces a bad-value error. +SYSTEM_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "system_buckets", + target_collection=SystemBucketsCollection(), + docs=None, + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="system.buckets.* should produce an illegal-operation error", + ), + CommandTestCase( + "system_views", + target_collection=SystemViewsCollection(), + docs=None, + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + error_code=BAD_VALUE_ERROR, + msg="system.views should produce a bad-value error", + ), + CommandTestCase( + "nonexistent_database", + target_collection=TargetDatabase(suffix="nonexist_xyz"), + docs=None, + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="Non-existent database should produce a namespace-not-found error", + ), +] + +# Property [Collection Name Max Length Error]: exceeding the byte-length +# limit produces an invalid namespace error. +NAME_MAX_LENGTH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_exceeds_max_length", + target_collection=NamedCollection( + suffix=lambda db_name, coll_name: "x" + * (255 - len(db_name.encode("utf-8")) - 26 - len(coll_name) + 1) + ), + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Name exceeding byte-length limit should produce an invalid namespace error", + ), +] + +# Property [Collection Name Case Sensitivity]: collection names are +# case-sensitive; a name with wrong case produces a namespace-not-found +# error rather than matching the existing collection. +NAME_CASE_SENSITIVITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wrong_case_not_found", + target_collection=NamedCollection(suffix="_TestColl"), + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection.lower(), + "size": 4096, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="Wrong case name should produce not-found error, not match", + ), +] + +CONVERT_TO_CAPPED_NAMESPACE_TESTS: list[CommandTestCase] = ( + NAME_MAX_LENGTH_SUCCESS_TESTS + + NAME_VALID_CHARS_TESTS + + NAME_TYPE_ERROR_TESTS + + NAME_VALUE_ERROR_TESTS + + NAME_DOLLAR_PREFIX_TESTS + + SYSTEM_NAMESPACE_ERROR_TESTS + + NAME_MAX_LENGTH_ERROR_TESTS + + NAME_CASE_SENSITIVITY_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(CONVERT_TO_CAPPED_NAMESPACE_TESTS)) +def test_convert_to_capped_namespace(database_client, collection, test): + """Test convertToCapped command namespace and collection name 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/convertToCapped/test_convertToCapped_size.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_size.py new file mode 100644 index 00000000..7d074b18 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_size.py @@ -0,0 +1,431 @@ +"""Tests for convertToCapped command - size parameter validation.""" + +import datetime + +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, + 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_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT64_ZERO, +) + +# Property [Size Parameter Success]: all numeric types that can represent a +# positive integer are accepted as the size parameter. +SIZE_PARAM_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_int32_exact", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096}, + expected={"ok": 1.0}, + msg="Int32 value should be accepted", + ), + CommandTestCase( + "size_int64_exact", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": Int64(8192)}, + expected={"ok": 1.0}, + msg="Int64 value should be accepted", + ), + CommandTestCase( + "size_double_whole_number", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 4096.0}, + expected={"ok": 1.0}, + msg="Whole-number double should be accepted", + ), + CommandTestCase( + "size_decimal128_whole_number", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": Decimal128("4096")}, + expected={"ok": 1.0}, + msg="Decimal128 whole number should be accepted", + ), + CommandTestCase( + "size_minimum_value_1", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 1}, + expected={"ok": 1.0}, + msg="Minimum accepted effective value 1 should succeed", + ), + CommandTestCase( + "size_maximum_value_1pb", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": Int64(1_125_899_906_842_624), + }, + expected={"ok": 1.0}, + msg="Maximum accepted value (1 PB) should succeed", + ), + CommandTestCase( + "size_double_fractional", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 3.9}, + expected={"ok": 1.0}, + msg="Double 3.9 (truncates to 3) should be accepted", + ), + CommandTestCase( + "size_decimal128_rounds_up", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": Decimal128("0.51")}, + expected={"ok": 1.0}, + msg="Decimal128 0.51 (rounds to 1) should be accepted", + ), + CommandTestCase( + "size_decimal128_rounds_0_6", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": Decimal128("0.6")}, + expected={"ok": 1.0}, + msg="Decimal128 0.6 rounds to 1, accepted as positive", + ), + CommandTestCase( + "size_decimal128_bankers_round_1_5", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_ONE_AND_HALF, + }, + expected={"ok": 1.0}, + msg="Decimal128 1.5 (banker's rounds to 2) should be accepted", + ), + CommandTestCase( + "size_decimal128_bankers_round_2_5", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_TWO_AND_HALF, + }, + expected={"ok": 1.0}, + msg="Decimal128 2.5 (banker's rounds to 2) should be accepted", + ), +] + +# Property [Missing Size]: omitting size produces an invalid options error. +MISSING_SIZE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "missing_size", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection}, + error_code=INVALID_OPTIONS_ERROR, + msg="Omitting size entirely should produce an invalid options error", + ), +] + +# Property [Size Parameter Errors - Non-Numeric Types]: all non-numeric +# types for the size field produce an invalid-options error. +SIZE_PARAM_NON_NUMERIC_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"size_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: {"convertToCapped": ctx.collection, "size": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"{id} size should be rejected as non-numeric", + ) + for id, val in [ + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("string", "4096"), + ("array", [4096]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex("pat", "i")), + ("code", Code("function() {}")), + ("code_with_scope", Code("function() {}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Size Parameter Errors - Zero and Negative]: zero, negative, +# and NaN values produce an invalid-options error. +SIZE_PARAM_ZERO_NEGATIVE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_int32_zero", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 0}, + error_code=INVALID_OPTIONS_ERROR, + msg="int32 zero should be rejected as not greater than zero", + ), + CommandTestCase( + "size_int64_zero", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": INT64_ZERO}, + error_code=INVALID_OPTIONS_ERROR, + msg="int64 zero should be rejected as not greater than zero", + ), + CommandTestCase( + "size_double_zero", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": DOUBLE_ZERO}, + error_code=INVALID_OPTIONS_ERROR, + msg="double 0.0 should be rejected as not greater than zero", + ), + CommandTestCase( + "size_double_negative_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DOUBLE_NEGATIVE_ZERO, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="double -0.0 should be rejected as not greater than zero", + ), + CommandTestCase( + "size_decimal128_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_ZERO, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128('0') should be rejected as not greater than zero", + ), + CommandTestCase( + "size_decimal128_negative_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_NEGATIVE_ZERO, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128('-0') should be rejected as not greater than zero", + ), + CommandTestCase( + "size_int32_negative", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": -1}, + error_code=INVALID_OPTIONS_ERROR, + msg="Negative int32 should be rejected as not greater than zero", + ), + CommandTestCase( + "size_int64_negative", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": Int64(-100), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Negative int64 should be rejected as not greater than zero", + ), + CommandTestCase( + "size_double_negative", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": -3.14}, + error_code=INVALID_OPTIONS_ERROR, + msg="Negative double should be rejected as not greater than zero", + ), + CommandTestCase( + "size_decimal128_negative", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": Decimal128("-100"), + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Negative Decimal128 should be rejected as not greater than zero", + ), + CommandTestCase( + "size_double_coerces_to_zero", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 0.9}, + error_code=INVALID_OPTIONS_ERROR, + msg="double 0.9 truncates to 0, should be rejected as not greater than zero", + ), + CommandTestCase( + "size_double_0_6_truncates_to_zero", + docs=[{"_id": 1}], + command=lambda ctx: {"convertToCapped": ctx.collection, "size": 0.6}, + error_code=INVALID_OPTIONS_ERROR, + msg="double 0.6 truncates to 0, rejected as not greater than zero", + ), + CommandTestCase( + "size_decimal128_coerces_to_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_HALF, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128 0.5 rounds to 0 via banker's rounding, rejected as not greater than zero", + ), + CommandTestCase( + "size_decimal128_subnormal", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_MIN_POSITIVE, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128 subnormal coerces to 0, rejected as not greater than zero", + ), + CommandTestCase( + "size_double_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": FLOAT_NAN, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="double NaN should be rejected as not greater than zero", + ), + CommandTestCase( + "size_decimal128_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_NAN, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128 NaN should be rejected as not greater than zero", + ), + CommandTestCase( + "size_double_negative_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": FLOAT_NEGATIVE_NAN, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="double -NaN should be rejected as not greater than zero", + ), + CommandTestCase( + "size_decimal128_negative_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_NEGATIVE_NAN, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128 -NaN should be rejected as not greater than zero", + ), + CommandTestCase( + "size_double_negative_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": FLOAT_NEGATIVE_INFINITY, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="double negative infinity should be rejected as not greater than zero", + ), + CommandTestCase( + "size_decimal128_negative_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_NEGATIVE_INFINITY, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="Decimal128 negative infinity should be rejected as not greater than zero", + ), +] + +# Property [Size Parameter Errors - Exceeds Upper Limit]: positive infinity +# and values exceeding 1 PB produce a bad-value error. +SIZE_PARAM_EXCEEDS_LIMIT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_double_positive_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": FLOAT_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="double positive infinity should be rejected as exceeding upper limit", + ), + CommandTestCase( + "size_decimal128_positive_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": DECIMAL128_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="Decimal128 positive infinity should be rejected as exceeding upper limit", + ), + CommandTestCase( + "size_int64_exceeds_1pb", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": Int64(1_125_899_906_842_625), + }, + error_code=BAD_VALUE_ERROR, + msg="int64 one above 1 PB should be rejected as exceeding upper limit", + ), + CommandTestCase( + "size_double_exceeds_limit_due_to_precision", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 1_125_899_906_842_624.9, + }, + error_code=BAD_VALUE_ERROR, + msg="double rounds to 1125899906842625 due to precision, exceeding 1PB limit", + ), +] + +CONVERT_TO_CAPPED_SIZE_TESTS: list[CommandTestCase] = ( + SIZE_PARAM_SUCCESS_TESTS + + MISSING_SIZE_TESTS + + SIZE_PARAM_NON_NUMERIC_ERROR_TESTS + + SIZE_PARAM_ZERO_NEGATIVE_ERROR_TESTS + + SIZE_PARAM_EXCEEDS_LIMIT_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(CONVERT_TO_CAPPED_SIZE_TESTS)) +def test_convert_to_capped_size(database_client, collection, test): + """Test convertToCapped command 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/convertToCapped/test_convertToCapped_wc_durability.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_durability.py new file mode 100644 index 00000000..dcecf25e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_durability.py @@ -0,0 +1,178 @@ +"""Tests for convertToCapped 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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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_convert_to_capped_wc_durability(database_client, collection, test): + """Test convertToCapped 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/convertToCapped/test_convertToCapped_wc_other_fields.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_other_fields.py new file mode 100644 index 00000000..1d0b98c1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_other_fields.py @@ -0,0 +1,230 @@ +"""Tests for convertToCapped 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]: wtimeout accepts all BSON +# types without error, including non-numeric types and negative values. +WRITECONCERN_WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"wtimeout_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "convertToCapped": ctx.collection, + "size": 100_000, + "writeConcern": {"wtimeout": v}, + }, + expected={"ok": 1.0}, + msg=f"wtimeout={id} should succeed", + ) + for id, val in [ + ("zero", 0), + ("positive", 1000), + ("negative", -1), + ("string", "hello"), + ("bool", True), + ("null", None), + ("array", [1, 2]), + ("object", {"a": 1}), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("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 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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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_convert_to_capped_wc_other_fields(database_client, collection, test): + """Test convertToCapped 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/convertToCapped/test_convertToCapped_wc_toplevel.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_toplevel.py new file mode 100644 index 00000000..4eef8cde --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_toplevel.py @@ -0,0 +1,116 @@ +"""Tests for convertToCapped 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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "size": 100_000, + }, + expected={"ok": 1.0}, + msg="Omitting writeConcern should succeed", + ), + CommandTestCase( + "wc_empty_document", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 100_000, + "writeConcern": {}, + }, + expected={"ok": 1.0}, + msg="Empty document writeConcern should be accepted", + ), + CommandTestCase( + "wc_null", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "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_convert_to_capped_wc_toplevel(database_client, collection, test): + """Test convertToCapped 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/convertToCapped/test_convertToCapped_wc_w.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_w.py new file mode 100644 index 00000000..42126e25 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_wc_w.py @@ -0,0 +1,538 @@ +"""Tests for convertToCapped 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_NEGATIVE_NAN, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, +) + +# 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: { + "convertToCapped": ctx.collection, + "size": 100_000, + "writeConcern": {"w": 0}, + }, + expected={"ok": 1.0}, + msg="w=0 should succeed", + ), + CommandTestCase( + "w_1", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 100_000, + "writeConcern": {"w": 1}, + }, + expected={"ok": 1.0}, + msg="w=1 should succeed", + ), + CommandTestCase( + "w_majority", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "size": 100_000, + "writeConcern": {"w": FLOAT_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w=NaN should fail with failed to parse", + ), + CommandTestCase( + "w_negative_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 100_000, + "writeConcern": {"w": FLOAT_NEGATIVE_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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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_negative_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 100_000, + "writeConcern": {"w": DECIMAL128_NEGATIVE_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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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: { + "convertToCapped": ctx.collection, + "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_convert_to_capped_wc_w(database_client, collection, test): + """Test convertToCapped 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/target_collection.py b/documentdb_tests/framework/target_collection.py index 6db5a3e3..7ccf16ad 100644 --- a/documentdb_tests/framework/target_collection.py +++ b/documentdb_tests/framework/target_collection.py @@ -7,6 +7,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -60,12 +61,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 can be a static string or a callable (db_name, coll_name) -> str + for cases where the suffix depends on runtime values. + """ + + suffix: str | Callable[[str, str], str] = "" def resolve(self, db: Database, collection: Collection) -> Collection: - name = f"{collection.name}{self.suffix}" + s = self.suffix(db.name, collection.name) if callable(self.suffix) else self.suffix + name = f"{collection.name}{s}" db.create_collection(name) return db[name] @@ -114,3 +120,29 @@ 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 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 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}"] From 2b6e895e71b455d57ae9d970507705d2182b9b9e Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 12 May 2026 14:37:10 -0700 Subject: [PATCH 2/2] Add readConcern tests Signed-off-by: Daniel Frankcom --- .../test_convertToCapped_read_concern.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_read_concern.py diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_read_concern.py b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_read_concern.py new file mode 100644 index 00000000..0228663b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/convertToCapped/test_convertToCapped_read_concern.py @@ -0,0 +1,148 @@ +"""Tests for convertToCapped readConcern behavior.""" + +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, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [readConcern Success Behavior]: readConcern with level +# "local", as an empty document, or as null is accepted. +READ_CONCERN_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="read_concern_local", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "readConcern": {"level": "local"}, + }, + expected={"ok": Eq(1.0)}, + msg="readConcern with level 'local' should succeed", + ), + CommandTestCase( + id="read_concern_empty", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "readConcern": {}, + }, + expected={"ok": Eq(1.0)}, + msg="readConcern={} should succeed", + ), + CommandTestCase( + id="read_concern_null", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "readConcern": None, + }, + expected={"ok": Eq(1.0)}, + msg="readConcern=null should succeed", + ), +] + +# Property [readConcern Level Unsupported]: non-local read concern +# levels produce INVALID_OPTIONS_ERROR. +READ_CONCERN_LEVEL_UNSUPPORTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id=f"unsupported_level_{level}", + docs=[{"_id": 1}], + command=lambda ctx, lvl=level: { + "convertToCapped": ctx.collection, + "size": 4096, + "readConcern": {"level": lvl}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=f"readConcern level '{level}' should produce INVALID_OPTIONS_ERROR", + ) + for level in ["available", "majority", "linearizable", "snapshot"] +] + +# Property [readConcern Level Invalid Name]: an unrecognized read +# concern level name produces BAD_VALUE_ERROR. +READ_CONCERN_LEVEL_INVALID_NAME_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="invalid_level", + docs=[{"_id": 1}], + command=lambda ctx: { + "convertToCapped": ctx.collection, + "size": 4096, + "readConcern": {"level": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="readConcern level 'invalid' should produce BAD_VALUE_ERROR", + ), +] + +# Property [readConcern Type Errors]: when readConcern is a +# non-document BSON type (excluding null), the command produces a TYPE_MISMATCH_ERROR. +READ_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id=f"read_concern_{id}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "convertToCapped": ctx.collection, + "size": 4096, + "readConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"readConcern={id} should produce TYPE_MISMATCH_ERROR", + ) + for id, val in [ + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("99")), + ("bool", True), + ("string", "local"), + ("array", [{"level": "local"}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +READ_CONCERN_TESTS: list[CommandTestCase] = ( + READ_CONCERN_SUCCESS_TESTS + + READ_CONCERN_LEVEL_UNSUPPORTED_TESTS + + READ_CONCERN_LEVEL_INVALID_NAME_TESTS + + READ_CONCERN_TYPE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(READ_CONCERN_TESTS)) +def test_convert_to_capped_read_concern(database_client, collection, test): + """Test convertToCapped readConcern 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, + )