From d41d619cf0a0fb372d7a80641ac669e4f1045098 Mon Sep 17 00:00:00 2001 From: krishnasai453 Date: Tue, 23 Jun 2026 23:11:09 -0400 Subject: [PATCH 01/51] Phase 3: Added implementation plan and details of implementation Signed-off-by: krishnasai453 --- .../bitwise/bitAnd/test_expression_bitAnd.py | 81 +++++++++++++++ ...est_expression_bitAnd_testing_strategy.txt | 99 +++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_testing_strategy.txt diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd.py b/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd.py new file mode 100644 index 000000000..909c46580 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd.py @@ -0,0 +1,81 @@ +""" +Tests for $bitAnd expression type smoke tests. + +Covers literal, field reference, and nested expression operator inputs. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + + +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_bit_and", + expression={"$bitAnd": [5, 3]}, + expected=1, + msg="$bitAnd of two literals should compute the bitwise AND result", + ), + ExpressionTestCase( + "literal_bit_and_with_zero", + expression={"$bitAnd": [0, 7]}, + expected=0, + msg="$bitAnd with zero should produce zero", + ), +] + +FIELD_REFERENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_reference_bit_and", + expression={"$bitAnd": ["$a", "$b"]}, + doc={"a": 5, "b": 3}, + expected=1, + msg="$bitAnd should compute the bitwise AND of two field values", + ), + ExpressionTestCase( + "field_reference_bit_and_secondary", + expression={"$bitAnd": ["$a", "$b"]}, + doc={"a": 12, "b": 10}, + expected=8, + msg="$bitAnd should compute the bitwise AND for other field values", + ), +] + +EXPRESSION_OPERATOR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_expression_bit_and", + expression={ + "$bitAnd": [ + {"$add": ["$a", 1]}, + {"$subtract": ["$b", 1]}, + ] + }, + doc={"a": 4, "b": 6}, + expected=5, + msg="$bitAnd should accept nested expression operator inputs", + ), +] + +ALL_INSERT_TESTS = FIELD_REFERENCE_TESTS + EXPRESSION_OPERATOR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_bitAnd_expression_types_literal(collection, test): + """Test $bitAnd with literal expression inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +@pytest.mark.parametrize("test", pytest_params(ALL_INSERT_TESTS)) +def test_bitAnd_expression_types_insert(collection, test): + """Test $bitAnd with field reference and nested expression inputs.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_testing_strategy.txt b/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_testing_strategy.txt new file mode 100644 index 000000000..2e9e5d735 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_testing_strategy.txt @@ -0,0 +1,99 @@ +""" +Tests for $bitAnd expression type smoke tests. + +Covers literal, field reference, expression operator, array expression, +and object expression. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + + +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_zero_and", + expression={"$bitAnd": [5, 3]}, + expected=1, + msg="$bitAnd of two literals should compute bitwise AND", + ), + ExpressionTestCase( + "literal_all_zero", + expression={"$bitAnd": [0, 7]}, + expected=0, + msg="$bitAnd with zero should produce zero", + ), +] + +FIELD_REFERENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_refs", + expression={"$bitAnd": ["$a", "$b"]}, + doc={"a": 5, "b": 3}, + expected=1, + msg="$bitAnd should compute bitwise AND of two field values", + ), + ExpressionTestCase( + "field_refs_negative", + expression={"$bitAnd": ["$a", "$b"]}, + doc={"a": 12, "b": 10}, + expected=8, + msg="$bitAnd should compute bitwise AND for other field values", + ), +] + +EXPRESSION_OPERATOR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_expression", + expression={"$bitAnd": [{"$add": [4, 1]}, {"$subtract": [6, 1]}]}, + expected=5, + msg="$bitAnd should accept nested expression operator inputs", + ), +] + +ARRAY_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_expression", + expression={"$bitAnd": [["$a", "$b"], 3]}, + doc={"a": 1, "b": 3}, + expected=1, + msg="$bitAnd should accept an array expression as first operand", + ), +] + +OBJECT_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "object_expression", + expression={"$bitAnd": [{"x": "$a", "y": "$b"}, 0]}, + doc={"a": 1, "b": 3}, + expected=0, + msg="$bitAnd should accept an object expression as an input", + ), +] + +ALL_INSERT_TESTS = ( + FIELD_REFERENCE_TESTS + + EXPRESSION_OPERATOR_TESTS + + ARRAY_EXPRESSION_TESTS + + OBJECT_EXPRESSION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_bitAnd_expression_types_literal(collection, test): + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +@pytest.mark.parametrize("test", pytest_params(ALL_INSERT_TESTS)) +def test_bitAnd_expression_types_insert(collection, test): + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) \ No newline at end of file From fa5bca0a9b9fc078e1dc71b0c5bc3815bedd0868 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 24 Jun 2026 15:21:23 -0700 Subject: [PATCH 02/51] Add changeStream stage tests (#601) Signed-off-by: Daniel Frankcom --- .../test_stages_position_changeStream.py | 290 +++++++++ .../test_changeStream_command_options.py | 72 +++ .../test_changeStream_early_start.py | 136 +++++ .../test_changeStream_event_read_error.py | 49 ++ .../changeStream/test_changeStream_events.py | 464 +++++++++++++++ .../test_changeStream_expanded_events.py | 273 +++++++++ .../test_changeStream_namespace_errors.py | 141 +++++ .../test_changeStream_namespace_scope.py | 98 ++++ .../changeStream/test_changeStream_resume.py | 550 ++++++++++++++++++ ..._changeStream_resume_mutual_exclusivity.py | 66 +++ .../test_changeStream_spec_acceptance.py | 227 ++++++++ .../test_changeStream_stable_api.py | 88 +++ .../test_changeStream_timestamp_boundary.py | 117 ++++ .../test_changeStream_validation_errors.py | 530 +++++++++++++++++ .../changeStream/utils/__init__.py | 0 .../changeStream/utils/changeStream_common.py | 60 ++ documentdb_tests/framework/error_codes.py | 11 + 17 files changed, 3172 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_changeStream.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_command_options.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_event_read_error.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_events.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_expanded_events.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_namespace_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_namespace_scope.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_resume.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_resume_mutual_exclusivity.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_spec_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_stable_api.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_validation_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/utils/changeStream_common.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_changeStream.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_changeStream.py new file mode 100644 index 000000000..2d05f0a82 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_changeStream.py @@ -0,0 +1,290 @@ +"""Tests for $changeStream pipeline position constraints and stage composition.""" + +from __future__ import annotations + +import pytest +from pymongo.collection import Collection +from pymongo.database import Database + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + ILLEGAL_OPERATION_ERROR, + NOT_FIRST_STAGE_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 +from documentdb_tests.framework.target_collection import ExistingDatabase + +# Property [Following Stage Allow-List]: $changeStream opens as the first stage +# and the pipeline opens successfully when followed by any stage permitted in +# its allow-list. +CHANGESTREAM_FOLLOWING_STAGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "following_match", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$match": {"operationType": "insert"}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $match", + ), + CommandTestCase( + "following_project", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$project": {"_id": 1}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $project", + ), + CommandTestCase( + "following_add_fields", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$addFields": {"x": 1}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $addFields", + ), + CommandTestCase( + "following_set", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$set": {"x": 1}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $set", + ), + CommandTestCase( + "following_replace_root", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$replaceRoot": {"newRoot": {"a": 1}}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $replaceRoot", + ), + CommandTestCase( + "following_replace_with", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$replaceWith": {"a": 1}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $replaceWith", + ), + CommandTestCase( + "following_redact", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$redact": "$$DESCEND"}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $redact", + ), + CommandTestCase( + "following_unset", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$unset": "x"}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $unset", + ), + CommandTestCase( + "following_fill", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$fill": {"output": {"x": {"value": 0}}}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $fill", + ), + CommandTestCase( + "following_change_stream_split_large_event", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$changeStreamSplitLargeEvent": {}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open when followed by $changeStreamSplitLargeEvent", + ), +] + +# Property [Disallowed Following Stage Rejection]: a stage outside the +# $changeStream allow-list placed after $changeStream is rejected, including +# stages that desugar to a disallowed system stage (e.g. $count and +# $sortByCount desugar to $group; $densify and $setWindowFields desugar to +# $sort; $fill with a sortBy desugars to $sort). +CHANGESTREAM_DISALLOWED_FOLLOWING_STAGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "disallowed_group", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$group": {"_id": "$operationType"}}], + "cursor": {}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="$changeStream should reject a following $group stage", + ), + CommandTestCase( + "disallowed_sort", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$sort": {"_id": 1}}], + "cursor": {}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="$changeStream should reject a following $sort stage", + ), + CommandTestCase( + "disallowed_count", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}, {"$count": "n"}], + "cursor": {}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="$changeStream should reject a following $count stage", + ), + CommandTestCase( + "disallowed_bucket", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [ + {"$changeStream": {}}, + {"$bucket": {"groupBy": "$x", "boundaries": [0, 1, 2], "default": "other"}}, + ], + "cursor": {}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="$changeStream should reject a following $bucket stage", + ), + CommandTestCase( + "disallowed_densify", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [ + {"$changeStream": {}}, + {"$densify": {"field": "x", "range": {"step": 1, "bounds": "full"}}}, + ], + "cursor": {}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="$changeStream should reject a following $densify stage", + ), + CommandTestCase( + "disallowed_set_window_fields", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [ + {"$changeStream": {}}, + {"$setWindowFields": {"sortBy": {"x": 1}, "output": {"n": {"$sum": 1}}}}, + ], + "cursor": {}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="$changeStream should reject a following $setWindowFields stage", + ), + CommandTestCase( + "disallowed_fill_with_sort_by", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [ + {"$changeStream": {}}, + {"$fill": {"sortBy": {"x": 1}, "output": {"y": {"method": "linear"}}}}, + ], + "cursor": {}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="$changeStream should reject a following $fill stage that desugars to $sort", + ), +] + +# Property [Stage Position Rejection]: $changeStream placed anywhere other than +# the first stage of the pipeline is rejected with a not-first-stage error in +# every namespace scope. +# +# The collection-less (database/cluster) forms use $documents as the leading +# stage because it is valid in a collection-less pipeline; a collection- +# requiring stage such as $match would fail with an invalid-namespace error +# before $changeStream's position check runs, masking it. +CHANGESTREAM_STAGE_POSITION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "not_first_collection", + docs=[{"_id": 1}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {}}, {"$changeStream": {}}], + "cursor": {}, + }, + error_code=NOT_FIRST_STAGE_ERROR, + msg="$changeStream should reject a non-first stage position in a collection pipeline", + ), + CommandTestCase( + "not_first_database", + docs=[{"_id": 1}], + command={ + "aggregate": 1, + "pipeline": [{"$documents": [{"x": 1}]}, {"$changeStream": {}}], + "cursor": {}, + }, + error_code=NOT_FIRST_STAGE_ERROR, + msg="$changeStream should reject a non-first stage position in a database-scoped pipeline", + ), + CommandTestCase( + "not_first_cluster", + target_collection=ExistingDatabase(db_name="admin"), + docs=None, + command={ + "aggregate": 1, + "pipeline": [ + {"$documents": [{"x": 1}]}, + {"$changeStream": {"allChangesForCluster": True}}, + ], + "cursor": {}, + }, + error_code=NOT_FIRST_STAGE_ERROR, + msg="$changeStream should reject a non-first stage position in a cluster-scoped pipeline", + ), +] + +CHANGESTREAM_POSITION_TESTS = ( + CHANGESTREAM_FOLLOWING_STAGE_TESTS + + CHANGESTREAM_DISALLOWED_FOLLOWING_STAGE_TESTS + + CHANGESTREAM_STAGE_POSITION_ERROR_TESTS +) + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test", pytest_params(CHANGESTREAM_POSITION_TESTS)) +def test_changeStream_position( + database_client: Database, collection: Collection, test: CommandTestCase +): + """Test $changeStream pipeline position constraints and stage composition.""" + target = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(target) + result = execute_command(target, 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/operator/system-stages/changeStream/test_changeStream_command_options.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_command_options.py new file mode 100644 index 000000000..4da64cc50 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_command_options.py @@ -0,0 +1,72 @@ +"""Tests for $changeStream top-level command option acceptance.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.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.property_checks import Eq + +# Property [Cursor and Command Options]: a stream opens when standard aggregate +# cursor and command options are supplied alongside the $changeStream pipeline. +CHANGESTREAM_COMMAND_OPTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "cursor_batch_size_zero", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {"batchSize": 0}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open with cursor.batchSize 0", + ), + CommandTestCase( + "cursor_batch_size_five", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {"batchSize": 5}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open with cursor.batchSize 5", + ), + CommandTestCase( + "max_time_ms", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {}, + "maxTimeMS": 1000, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open with maxTimeMS", + ), + CommandTestCase( + "collation", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {}, + "collation": {"locale": "en"}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open with a top-level collation", + ), +] + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test", pytest_params(CHANGESTREAM_COMMAND_OPTION_TESTS)) +def test_changeStream_command_options(database_client, collection, test): + """Test $changeStream opens with standard aggregate cursor and command options.""" + target = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(target) + result = execute_command(target, test.build_command(ctx)) + assertResult(result, expected=test.build_expected(ctx), msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py new file mode 100644 index 000000000..f0f53ebc0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py @@ -0,0 +1,136 @@ +"""Tests for $changeStream startAtOperationTime at or before the oldest oplog entry. + +A startAtOperationTime at or before the oldest retained oplog entry is accepted: +the stream opens and begins from the earliest available event. (It is not +rejected as history lost; that error is reserved for resuming from a point that +was retained and has since been evicted, which a controlled test environment +cannot reliably force.) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +import pytest +from bson import Timestamp +from utils.changeStream_common import change_stream_command + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_case import BaseTestCase + + +# Return the timestamp of the oldest retained oplog entry, so a start point can +# be expressed relative to the live oplog rather than as a static value. +def _oldest_oplog_ts(collection) -> Timestamp: + oldest = ( + collection.database.client["local"]["oplog.rs"] + .find() + .sort("$natural", 1) + .limit(1) + .next()["ts"] + ) + return cast(Timestamp, oldest) + + +@dataclass(frozen=True) +class ChangeStreamEarlyStartTestCase(BaseTestCase): + """Test case for a startAtOperationTime at or before the oldest oplog entry. + + Attributes: + compute_start: Receives the timestamp of the oldest retained oplog entry + captured at run time and returns the startAtOperationTime to test, so + the boundary case is expressed relative to the live oplog rather than + as a static value. + """ + + compute_start: Any = None + + +# Property [Early Start Accepted]: a startAtOperationTime at or before the oldest +# retained oplog entry opens the stream from the earliest available event rather +# than being rejected, including the boundary five seconds before the oldest +# retained entry. This is verified identically across collection-, database-, and +# cluster-scoped streams. +CHANGESTREAM_EARLY_START_TESTS: list[ChangeStreamEarlyStartTestCase] = [ + ChangeStreamEarlyStartTestCase( + "zero", + compute_start=lambda oldest: Timestamp(0, 0), + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept a zero startAtOperationTime", + ), + ChangeStreamEarlyStartTestCase( + "one_zero", + compute_start=lambda oldest: Timestamp(1, 0), + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept a startAtOperationTime before the oldest oplog entry", + ), + ChangeStreamEarlyStartTestCase( + "max_increment", + compute_start=lambda oldest: Timestamp(1, 4_294_967_295), + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept a startAtOperationTime before the oldest oplog entry", + ), + ChangeStreamEarlyStartTestCase( + "oldest_minus_5s", + compute_start=lambda oldest: Timestamp(oldest.time - 5, oldest.inc), + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept a startAtOperationTime five seconds before" + " the oldest retained oplog entry", + ), +] + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_EARLY_START_TESTS)) +def test_changeStream_early_start_collection_scope( + collection, test_case: ChangeStreamEarlyStartTestCase +): + """Test $changeStream accepts an early startAtOperationTime on a collection-scoped stream.""" + start = test_case.compute_start(_oldest_oplog_ts(collection)) + result = execute_command( + collection, + change_stream_command( + collection, pipeline=[{"$changeStream": {"startAtOperationTime": start}}] + ), + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_EARLY_START_TESTS)) +def test_changeStream_early_start_database_scope( + collection, test_case: ChangeStreamEarlyStartTestCase +): + """Test $changeStream accepts an early startAtOperationTime on a database-scoped stream.""" + start = test_case.compute_start(_oldest_oplog_ts(collection)) + result = execute_command( + collection, + change_stream_command( + collection, + pipeline=[{"$changeStream": {"startAtOperationTime": start}}], + aggregate=1, + ), + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_EARLY_START_TESTS)) +def test_changeStream_early_start_cluster_scope( + collection, test_case: ChangeStreamEarlyStartTestCase +): + """Test $changeStream accepts an early startAtOperationTime on a cluster-wide stream.""" + start = test_case.compute_start(_oldest_oplog_ts(collection)) + spec = {"startAtOperationTime": start, "allChangesForCluster": True} + result = execute_admin_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": spec}], aggregate=1), + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_event_read_error.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_event_read_error.py new file mode 100644 index 000000000..870cd7014 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_event_read_error.py @@ -0,0 +1,49 @@ +"""Tests for $changeStream event read errors when a required pre/post image is missing.""" + +from __future__ import annotations + +import pytest +from utils.changeStream_common import change_stream_command, get_more_command + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import NO_MATCHING_DOCUMENT_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Required Image Event-Read Error]: a required-image mode on a +# collection without pre/post images enabled fails when reading the update event. +CHANGESTREAM_EVENT_READ_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "full_document_required_no_images", + pipeline=[{"$changeStream": {"fullDocument": "required"}}], + error_code=NO_MATCHING_DOCUMENT_ERROR, + msg="$changeStream fullDocument required should open but fail reading an update" + " event without pre/post images enabled", + ), + StageTestCase( + "full_document_before_change_required_no_images", + pipeline=[{"$changeStream": {"fullDocumentBeforeChange": "required"}}], + error_code=NO_MATCHING_DOCUMENT_ERROR, + msg="$changeStream fullDocumentBeforeChange required should open but fail reading" + " an update event without pre/post images enabled", + ), +] + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_EVENT_READ_ERROR_TESTS)) +def test_changeStream_required_image_event_read_error(collection, test_case): + """Test $changeStream defers required-image enforcement to event-read time.""" + collection.insert_one({"_id": 1, "a": 1}) + # The open must succeed: enforcement of the required image is deferred to the + # getMore that reads the update event, not raised at parse/open time. + opened = execute_command( + collection, change_stream_command(collection, pipeline=test_case.pipeline) + ) + collection.update_one({"_id": 1}, {"$set": {"a": 2}}) + result = execute_command(collection, get_more_command(collection, opened["cursor"]["id"])) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_events.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_events.py new file mode 100644 index 000000000..bdb9eba66 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_events.py @@ -0,0 +1,464 @@ +"""Tests for $changeStream emitted event document structure.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +import pytest +from bson import Binary, Code, DBRef, Int64, MaxKey, MinKey, ObjectId, Timestamp +from utils.changeStream_common import change_stream_command, get_more_command + +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, NotExists +from documentdb_tests.framework.test_case import BaseTestCase +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + + +@dataclass(frozen=True) +class Mutation: + """A change to perform against a collection, observed as one change event. + + Attributes: + operation_type: The ``operationType`` the resulting event carries, used + to pick the event out of the drained batch + apply: Performs the observed change after the stream is open + seed: Document inserted before the stream opens so the mutation has + something to act on (None for inserts) + """ + + operation_type: str + apply: Callable[[Any], None] + seed: dict[str, Any] | None = None + + +def _insert(doc: dict[str, Any]) -> Mutation: + """Insert ``doc`` after the stream opens; the insert is the observed event.""" + return Mutation("insert", lambda c: c.insert_one(dict(doc))) + + +def _update(seed: dict[str, Any], spec: Any) -> Mutation: + """Seed ``seed``, then apply update ``spec`` to it after the stream opens.""" + return Mutation("update", lambda c: c.update_one({"_id": seed["_id"]}, spec), seed=seed) + + +def _replace(seed: dict[str, Any], replacement: dict[str, Any]) -> Mutation: + """Seed ``seed``, then replace it with ``replacement`` after the stream opens.""" + return Mutation( + "replace", lambda c: c.replace_one({"_id": seed["_id"]}, dict(replacement)), seed=seed + ) + + +def _delete(seed: dict[str, Any]) -> Mutation: + """Seed ``seed``, then delete it after the stream opens.""" + return Mutation("delete", lambda c: c.delete_one({"_id": seed["_id"]}), seed=seed) + + +@dataclass(frozen=True) +class ChangeStreamEventTestCase(BaseTestCase): + """Test case for $changeStream emitted event structure. + + Drives an imperative open-mutate-drain sequence: the stream is opened with + ``pipeline`` (the full ``[{"$changeStream": ...}]`` pipeline), ``mutation`` + is performed, and the inherited ``expected`` (a property-check map) asserts + envelope/payload fields on the resulting event. + + Attributes: + pipeline: The full ``[{"$changeStream": ...}]`` pipeline to open with + (required) + mutation: Fully describes the observed write, see ``Mutation`` (required) + pre_and_post_images: When true, enables pre/post image capture before + the stream opens + post_mutation: An operation run after the observed mutation and before + the stream is drained, e.g. deleting the document so a later + updateLookup finds nothing (None when not needed) + """ + + pipeline: list[dict[str, Any]] | None = None + mutation: Mutation | None = None + pre_and_post_images: bool = False + post_mutation: Callable[[Any], None] | None = None + + def __post_init__(self): + super().__post_init__() + if self.pipeline is None: + raise ValueError(f"ChangeStreamEventTestCase '{self.id}' must set pipeline") + if self.mutation is None: + raise ValueError(f"ChangeStreamEventTestCase '{self.id}' must set mutation") + + +# Property [Event Envelope Fields]: every change event carries the common +# envelope fields with their documented BSON types, and operationType equals the +# name of the operation that produced the event. +CHANGESTREAM_EVENT_ENVELOPE_TESTS: list[ChangeStreamEventTestCase] = [ + ChangeStreamEventTestCase( + "envelope_insert", + pipeline=[{"$changeStream": {}}], + mutation=_insert({"_id": 1, "a": 1}), + expected={ + "_id._data": IsType("string"), + "operationType": Eq("insert"), + "clusterTime": IsType("timestamp"), + "wallTime": IsType("date"), + "ns.db": IsType("string"), + "ns.coll": IsType("string"), + "documentKey": IsType("object"), + }, + msg="$changeStream insert event should carry the common envelope fields", + ), + ChangeStreamEventTestCase( + "envelope_update", + pipeline=[{"$changeStream": {}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={ + "_id._data": IsType("string"), + "operationType": Eq("update"), + "clusterTime": IsType("timestamp"), + "wallTime": IsType("date"), + "ns.db": IsType("string"), + "ns.coll": IsType("string"), + "documentKey": IsType("object"), + }, + msg="$changeStream update event should carry the common envelope fields", + ), + ChangeStreamEventTestCase( + "envelope_replace", + pipeline=[{"$changeStream": {}}], + mutation=_replace({"_id": 1, "a": 1}, {"b": 2}), + expected={ + "_id._data": IsType("string"), + "operationType": Eq("replace"), + "clusterTime": IsType("timestamp"), + "wallTime": IsType("date"), + "ns.db": IsType("string"), + "ns.coll": IsType("string"), + "documentKey": IsType("object"), + }, + msg="$changeStream replace event should carry the common envelope fields", + ), + ChangeStreamEventTestCase( + "envelope_delete", + pipeline=[{"$changeStream": {}}], + mutation=_delete({"_id": 1, "a": 1}), + expected={ + "_id._data": IsType("string"), + "operationType": Eq("delete"), + "clusterTime": IsType("timestamp"), + "wallTime": IsType("date"), + "ns.db": IsType("string"), + "ns.coll": IsType("string"), + "documentKey": IsType("object"), + }, + msg="$changeStream delete event should carry the common envelope fields", + ), +] + +# Property [documentKey Id Type Preservation]: documentKey._id preserves the BSON +# type and value of the source document's _id. +CHANGESTREAM_EVENT_DOCUMENT_KEY_TESTS: list[ChangeStreamEventTestCase] = [ + ChangeStreamEventTestCase( + f"document_key_{tid}", + pipeline=[{"$changeStream": {}}], + mutation=_insert({"_id": val, "marker": 1}), + expected={"documentKey._id": Eq(val)}, + msg=f"$changeStream should preserve a {tid} _id in documentKey", + ) + for tid, val in [ + ("int32", 7), + ("int64", Int64(7)), + ("double", 3.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "abc"), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01" * 16, 4)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("dbref", DBRef("c", 1)), + ("object", {"x": 1}), + ("null", None), + ] +] + +# Property [Operation Payload Fields]: each operationType carries exactly the +# payload fields documented for it, and insert carries fullDocument regardless +# of the fullDocument option. +CHANGESTREAM_EVENT_PAYLOAD_TESTS: list[ChangeStreamEventTestCase] = [ + ChangeStreamEventTestCase( + "payload_insert", + pipeline=[{"$changeStream": {}}], + mutation=_insert({"_id": 1, "a": 1}), + expected={ + "fullDocument": Eq({"_id": 1, "a": 1}), + "updateDescription": NotExists(), + }, + msg="$changeStream insert event should carry fullDocument and no updateDescription", + ), + ChangeStreamEventTestCase( + "payload_insert_full_document_mode", + pipeline=[{"$changeStream": {"fullDocument": "updateLookup"}}], + mutation=_insert({"_id": 1, "a": 1}), + expected={ + "fullDocument": Eq({"_id": 1, "a": 1}), + "updateDescription": NotExists(), + }, + msg=( + "$changeStream insert event should carry fullDocument regardless of" + " the fullDocument option" + ), + ), + ChangeStreamEventTestCase( + "payload_replace", + pipeline=[{"$changeStream": {}}], + mutation=_replace({"_id": 1, "a": 1}, {"b": 2}), + expected={ + "fullDocument": Eq({"_id": 1, "b": 2}), + "updateDescription": NotExists(), + }, + msg="$changeStream replace event should carry fullDocument and no updateDescription", + ), + ChangeStreamEventTestCase( + "payload_delete", + pipeline=[{"$changeStream": {}}], + mutation=_delete({"_id": 1, "a": 1}), + expected={ + "fullDocument": NotExists(), + "updateDescription": NotExists(), + }, + msg="$changeStream delete event should carry neither fullDocument nor updateDescription", + ), + ChangeStreamEventTestCase( + "payload_update", + pipeline=[{"$changeStream": {}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={ + "updateDescription": Exists(), + }, + msg="$changeStream update event should always carry updateDescription", + ), +] + +# Property [updateDescription Structure]: an update event's updateDescription +# records changed paths in updatedFields (new nested paths as nested objects), +# removed paths in removedFields, and end-truncations in truncatedArrays, +# independently of the fullDocument mode. +CHANGESTREAM_EVENT_UPDATE_DESCRIPTION_TESTS: list[ChangeStreamEventTestCase] = [ + ChangeStreamEventTestCase( + "update_description_set_unset", + pipeline=[{"$changeStream": {}}], + mutation=_update({"_id": 1, "a": 1, "b": 1}, {"$set": {"b": 2}, "$unset": {"a": ""}}), + expected={ + "updateDescription": Eq( + {"updatedFields": {"b": 2}, "removedFields": ["a"], "truncatedArrays": []} + ) + }, + msg="$changeStream updateDescription should record set paths and removed fields", + ), + ChangeStreamEventTestCase( + "update_description_nested_set", + pipeline=[{"$changeStream": {}}], + mutation=_update({"_id": 1}, {"$set": {"nested.x": 5}}), + expected={"updateDescription.updatedFields": Eq({"nested": {"x": 5}})}, + msg="$changeStream updateDescription should represent a new nested path as a nested object", + ), + ChangeStreamEventTestCase( + "update_description_truncated_arrays", + pipeline=[{"$changeStream": {}}], + mutation=_update( + {"_id": 1, "arr": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}, + [{"$set": {"arr": {"$slice": ["$arr", 5]}}}], + ), + expected={"updateDescription.truncatedArrays": Eq([{"field": "arr", "newSize": 5}])}, + msg="$changeStream updateDescription should record end-truncation in truncatedArrays", + ), + ChangeStreamEventTestCase( + "update_description_with_full_document_mode", + pipeline=[{"$changeStream": {"fullDocument": "updateLookup"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={ + "updateDescription": Eq( + {"updatedFields": {"a": 2}, "removedFields": [], "truncatedArrays": []} + ) + }, + msg="$changeStream updateDescription should be emitted regardless of the fullDocument mode", + ), +] + +# Property [fullDocument Event Behavior]: an update event's fullDocument field +# tracks the fullDocument mode and pre/post-image availability. +CHANGESTREAM_EVENT_FULL_DOCUMENT_TESTS: list[ChangeStreamEventTestCase] = [ + ChangeStreamEventTestCase( + "full_document_default_omitted", + pipeline=[{"$changeStream": {}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={"fullDocument": NotExists()}, + msg="$changeStream update event should omit fullDocument when the mode is omitted", + ), + ChangeStreamEventTestCase( + "full_document_default_string", + pipeline=[{"$changeStream": {"fullDocument": "default"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={"fullDocument": NotExists()}, + msg="$changeStream update event should omit fullDocument under the default mode", + ), + ChangeStreamEventTestCase( + "full_document_null", + pipeline=[{"$changeStream": {"fullDocument": None}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={"fullDocument": NotExists()}, + msg="$changeStream update event should omit fullDocument when the mode is null", + ), + ChangeStreamEventTestCase( + "full_document_update_lookup", + pipeline=[{"$changeStream": {"fullDocument": "updateLookup"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={"fullDocument": Eq({"_id": 1, "a": 2})}, + msg="$changeStream updateLookup should carry the current document", + ), + ChangeStreamEventTestCase( + "full_document_update_lookup_deleted", + pipeline=[{"$changeStream": {"fullDocument": "updateLookup"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + post_mutation=lambda collection: collection.delete_one({"_id": 1}), + expected={"fullDocument": Eq(None)}, + msg="$changeStream updateLookup should carry null when the document no longer exists", + ), + ChangeStreamEventTestCase( + "full_document_when_available_no_images", + pipeline=[{"$changeStream": {"fullDocument": "whenAvailable"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={"fullDocument": Eq(None)}, + msg="$changeStream whenAvailable should carry null without pre/post images enabled", + ), + ChangeStreamEventTestCase( + "full_document_when_available_with_images", + pipeline=[{"$changeStream": {"fullDocument": "whenAvailable"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + pre_and_post_images=True, + expected={"fullDocument": Eq({"_id": 1, "a": 2})}, + msg="$changeStream whenAvailable should carry the post-image with pre/post images enabled", + ), + ChangeStreamEventTestCase( + "full_document_required_with_images", + pipeline=[{"$changeStream": {"fullDocument": "required"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + pre_and_post_images=True, + expected={"fullDocument": Eq({"_id": 1, "a": 2})}, + msg="$changeStream required should carry the post-image with pre/post images enabled", + ), +] + +# Property [fullDocumentBeforeChange Event Behavior]: an update event's +# fullDocumentBeforeChange tracks the fullDocumentBeforeChange mode and +# pre-image availability, independently of any fullDocument post-image. +CHANGESTREAM_EVENT_FULL_DOCUMENT_BEFORE_CHANGE_TESTS: list[ChangeStreamEventTestCase] = [ + ChangeStreamEventTestCase( + "before_change_off", + pipeline=[{"$changeStream": {"fullDocumentBeforeChange": "off"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={"fullDocumentBeforeChange": NotExists()}, + msg="$changeStream update event should omit fullDocumentBeforeChange under the off mode", + ), + ChangeStreamEventTestCase( + "before_change_when_available_no_images", + pipeline=[{"$changeStream": {"fullDocumentBeforeChange": "whenAvailable"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + expected={"fullDocumentBeforeChange": Eq(None)}, + msg=( + "$changeStream whenAvailable should carry a null fullDocumentBeforeChange" + " without pre/post images enabled" + ), + ), + ChangeStreamEventTestCase( + "before_change_when_available_with_images", + pipeline=[{"$changeStream": {"fullDocumentBeforeChange": "whenAvailable"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + pre_and_post_images=True, + expected={"fullDocumentBeforeChange": Eq({"_id": 1, "a": 1})}, + msg="$changeStream whenAvailable should carry the pre-image with pre/post images enabled", + ), + ChangeStreamEventTestCase( + "before_change_required_with_images", + pipeline=[{"$changeStream": {"fullDocumentBeforeChange": "required"}}], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + pre_and_post_images=True, + expected={"fullDocumentBeforeChange": Eq({"_id": 1, "a": 1})}, + msg="$changeStream required should carry the pre-image with pre/post images enabled", + ), + ChangeStreamEventTestCase( + "before_change_and_full_document_both_images", + pipeline=[ + { + "$changeStream": { + "fullDocument": "whenAvailable", + "fullDocumentBeforeChange": "whenAvailable", + } + } + ], + mutation=_update({"_id": 1, "a": 1}, {"$set": {"a": 2}}), + pre_and_post_images=True, + expected={ + "fullDocument": Eq({"_id": 1, "a": 2}), + "fullDocumentBeforeChange": Eq({"_id": 1, "a": 1}), + }, + msg=( + "$changeStream should carry the post-image in fullDocument and the pre-image" + " in fullDocumentBeforeChange independently when both modes are set" + ), + ), +] + +CHANGESTREAM_EVENT_STRUCTURE_TESTS = ( + CHANGESTREAM_EVENT_ENVELOPE_TESTS + + CHANGESTREAM_EVENT_DOCUMENT_KEY_TESTS + + CHANGESTREAM_EVENT_PAYLOAD_TESTS + + CHANGESTREAM_EVENT_UPDATE_DESCRIPTION_TESTS + + CHANGESTREAM_EVENT_FULL_DOCUMENT_TESTS + + CHANGESTREAM_EVENT_FULL_DOCUMENT_BEFORE_CHANGE_TESTS +) + + +def _emit_event(collection, test_case: ChangeStreamEventTestCase) -> dict[str, Any]: + """Open a stream, perform the test case mutation, and return the matching event. + + The mutation happens after the stream is open, so its event is delivered by + the first getMore. + """ + mutation = test_case.mutation + assert mutation is not None # guaranteed by __post_init__ + if mutation.seed is not None: + collection.insert_one(dict(mutation.seed)) + if test_case.pre_and_post_images: + execute_command( + collection, + {"collMod": collection.name, "changeStreamPreAndPostImages": {"enabled": True}}, + ) + opened = execute_command( + collection, change_stream_command(collection, pipeline=test_case.pipeline) + ) + mutation.apply(collection) + if test_case.post_mutation is not None: + test_case.post_mutation(collection) + batch = execute_command(collection, get_more_command(collection, opened["cursor"]["id"]))[ + "cursor" + ]["nextBatch"] + matching = [e for e in batch if e["operationType"] == mutation.operation_type] + assert matching, f"no {mutation.operation_type} event in batch: {batch!r}" + event: dict[str, Any] = matching[0] + return event + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_EVENT_STRUCTURE_TESTS)) +def test_changeStream_event_structure(collection, test_case: ChangeStreamEventTestCase): + """Test $changeStream emitted event structure.""" + event = _emit_event(collection, test_case) + assertProperties(event, test_case.expected, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_expanded_events.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_expanded_events.py new file mode 100644 index 000000000..026d0e7cc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_expanded_events.py @@ -0,0 +1,273 @@ +"""Tests for $changeStream showExpandedEvents operationType gating.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import pytest +from utils.changeStream_common import change_stream_command, get_more_command + +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Contains, NotContains +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class DdlOperation: + """A DDL/metadata operation observed through a change stream. + + Attributes: + operation_type: The ``operationType`` the resulting event carries, used + to find the event among the drained events + command: Builds the command document from the fixture collection + scope: The change-stream scope the operation must be observed at + (``collection`` or ``database``) + admin: Routes ``command`` through ``execute_admin_command`` rather than + ``execute_command`` (e.g. rename) + setup: Builds a precondition command run before the stream opens, e.g. + creating an index so it can later be dropped (None when not needed) + """ + + operation_type: str + command: Callable[[Any], dict[str, Any]] + scope: str = "collection" + admin: bool = False + setup: Callable[[Any], dict[str, Any]] | None = None + + +def _create_index_command(collection) -> dict[str, Any]: + return {"createIndexes": collection.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]} + + +def _create_index() -> DdlOperation: + """Create an index; emits a createIndexes event.""" + return DdlOperation("createIndexes", _create_index_command) + + +def _drop_index() -> DdlOperation: + """Drop an index (created during setup); emits a dropIndexes event.""" + return DdlOperation( + "dropIndexes", + lambda c: {"dropIndexes": c.name, "index": "a_1"}, + setup=_create_index_command, + ) + + +def _coll_mod() -> DdlOperation: + """Modify collection options; emits a modify event.""" + return DdlOperation("modify", lambda c: {"collMod": c.name, "validationLevel": "moderate"}) + + +def _create() -> DdlOperation: + """Create a sibling collection; emits a create event on a database-scoped stream.""" + return DdlOperation("create", lambda c: {"create": f"{c.name}_ddl"}, scope="database") + + +def _drop() -> DdlOperation: + """Drop the collection; emits a drop event.""" + return DdlOperation("drop", lambda c: {"drop": c.name}) + + +def _rename() -> DdlOperation: + """Rename the collection; emits a rename event.""" + return DdlOperation( + "rename", + lambda c: { + "renameCollection": f"{c.database.name}.{c.name}", + "to": f"{c.database.name}.{c.name}_renamed", + }, + admin=True, + ) + + +@dataclass(frozen=True) +class ChangeStreamGatingTestCase(BaseTestCase): + """Test case for $changeStream showExpandedEvents operationType gating. + + Drives an open-DDL-drain sequence: the stream is opened with ``pipeline`` + (the full ``[{"$changeStream": ...}]`` pipeline) at the scope ``ddl`` + requires, the DDL operation is performed, and the drained command result is + checked against the inherited ``expected`` property map. + + Attributes: + pipeline: The full ``[{"$changeStream": ...}]`` pipeline to open with, + at the scope ``ddl`` requires (required) + ddl: The DDL operation to perform, see ``DdlOperation`` (required) + expected: A property-check map over the raw getMore result; for a + gating case it asserts the cursor batch ``Contains`` (shown) or + ``NotContains`` (suppressed) an event with the ddl's operationType + (inherited from ``BaseTestCase``) + """ + + pipeline: list[dict[str, Any]] | None = None + ddl: DdlOperation | None = None + + def __post_init__(self): + super().__post_init__() + if self.pipeline is None: + raise ValueError(f"ChangeStreamGatingTestCase '{self.id}' must set pipeline") + if self.ddl is None: + raise ValueError(f"ChangeStreamGatingTestCase '{self.id}' must set ddl") + + +# Property [Expanded Event Gating]: showExpandedEvents true surfaces the +# expanded DDL/index events that showExpandedEvents false or omission suppresses. +CHANGESTREAM_EXPANDED_EVENT_GATING_TESTS: list[ChangeStreamGatingTestCase] = [ + ChangeStreamGatingTestCase( + "create_index_shown_when_true", + pipeline=[{"$changeStream": {"showExpandedEvents": True}}], + ddl=_create_index(), + expected={"cursor": {"nextBatch": Contains("operationType", "createIndexes")}}, + msg="$changeStream should emit a createIndexes event when showExpandedEvents is true", + ), + ChangeStreamGatingTestCase( + "create_index_suppressed_when_false", + pipeline=[{"$changeStream": {"showExpandedEvents": False}}], + ddl=_create_index(), + expected={"cursor": {"nextBatch": NotContains("operationType", "createIndexes")}}, + msg=( + "$changeStream should suppress the createIndexes event when" + " showExpandedEvents is false" + ), + ), + ChangeStreamGatingTestCase( + "create_index_suppressed_when_omitted", + pipeline=[{"$changeStream": {}}], + ddl=_create_index(), + expected={"cursor": {"nextBatch": NotContains("operationType", "createIndexes")}}, + msg=( + "$changeStream should suppress the createIndexes event when" + " showExpandedEvents is omitted" + ), + ), + ChangeStreamGatingTestCase( + "coll_mod_shown_when_true", + pipeline=[{"$changeStream": {"showExpandedEvents": True}}], + ddl=_coll_mod(), + expected={"cursor": {"nextBatch": Contains("operationType", "modify")}}, + msg="$changeStream should emit a modify event when showExpandedEvents is true", + ), + ChangeStreamGatingTestCase( + "coll_mod_suppressed_when_false", + pipeline=[{"$changeStream": {"showExpandedEvents": False}}], + ddl=_coll_mod(), + expected={"cursor": {"nextBatch": NotContains("operationType", "modify")}}, + msg="$changeStream should suppress the modify event when showExpandedEvents is false", + ), + ChangeStreamGatingTestCase( + "drop_index_shown_when_true", + pipeline=[{"$changeStream": {"showExpandedEvents": True}}], + ddl=_drop_index(), + expected={"cursor": {"nextBatch": Contains("operationType", "dropIndexes")}}, + msg="$changeStream should emit a dropIndexes event when showExpandedEvents is true", + ), + ChangeStreamGatingTestCase( + "drop_index_suppressed_when_false", + pipeline=[{"$changeStream": {"showExpandedEvents": False}}], + ddl=_drop_index(), + expected={"cursor": {"nextBatch": NotContains("operationType", "dropIndexes")}}, + msg="$changeStream should suppress the dropIndexes event when showExpandedEvents is false", + ), + ChangeStreamGatingTestCase( + "create_shown_when_true", + pipeline=[{"$changeStream": {"showExpandedEvents": True}}], + ddl=_create(), + expected={"cursor": {"nextBatch": Contains("operationType", "create")}}, + msg="$changeStream should emit a create event when showExpandedEvents is true", + ), + ChangeStreamGatingTestCase( + "create_suppressed_when_false", + pipeline=[{"$changeStream": {"showExpandedEvents": False}}], + ddl=_create(), + expected={"cursor": {"nextBatch": NotContains("operationType", "create")}}, + msg="$changeStream should suppress the create event when showExpandedEvents is false", + ), +] + +# Property [DDL Event Emission Regardless Of showExpandedEvents]: drop and +# rename events are emitted whether showExpandedEvents is true or false. +CHANGESTREAM_DDL_ALWAYS_EMITTED_TESTS: list[ChangeStreamGatingTestCase] = [ + ChangeStreamGatingTestCase( + "drop_emitted_true", + pipeline=[{"$changeStream": {"showExpandedEvents": True}}], + ddl=_drop(), + expected={"cursor": {"nextBatch": Contains("operationType", "drop")}}, + msg="$changeStream should emit a drop event when showExpandedEvents is true", + ), + ChangeStreamGatingTestCase( + "drop_emitted_false", + pipeline=[{"$changeStream": {"showExpandedEvents": False}}], + ddl=_drop(), + expected={"cursor": {"nextBatch": Contains("operationType", "drop")}}, + msg="$changeStream should emit a drop event when showExpandedEvents is false", + ), + ChangeStreamGatingTestCase( + "rename_emitted_true", + pipeline=[{"$changeStream": {"showExpandedEvents": True}}], + ddl=_rename(), + expected={"cursor": {"nextBatch": Contains("operationType", "rename")}}, + msg="$changeStream should emit a rename event when showExpandedEvents is true", + ), + ChangeStreamGatingTestCase( + "rename_emitted_false", + pipeline=[{"$changeStream": {"showExpandedEvents": False}}], + ddl=_rename(), + expected={"cursor": {"nextBatch": Contains("operationType", "rename")}}, + msg="$changeStream should emit a rename event when showExpandedEvents is false", + ), +] + +CHANGESTREAM_EXPANDED_EVENT_TESTS = ( + CHANGESTREAM_EXPANDED_EVENT_GATING_TESTS + CHANGESTREAM_DDL_ALWAYS_EMITTED_TESTS +) + + +def _emit_ddl_result(collection, database_client, test_case: ChangeStreamGatingTestCase) -> Any: + """Open a stream, perform the DDL operation, and return the raw getMore result. + + For a suppression case a marker insert follows the DDL so the getMore + returns an event instead of blocking until maxTimeMS. + """ + ddl = test_case.ddl + assert ddl is not None # guaranteed by __post_init__ + suppression = isinstance(test_case.expected["cursor"]["nextBatch"], NotContains) + database_scope = ddl.scope == "database" + + if not database_scope: + collection.insert_one({"_id": 1}) + if ddl.setup is not None: + execute_command(collection, ddl.setup(collection)) + + open_kwargs = {"aggregate": 1} if database_scope else {} + opened = execute_command( + collection, change_stream_command(collection, pipeline=test_case.pipeline, **open_kwargs) + ) + + run = execute_admin_command if ddl.admin else execute_command + run(collection, ddl.command(collection)) + + if suppression: + if database_scope: + database_client[f"{collection.name}_ddl"].insert_one({"_id": 1}) + else: + collection.insert_one({"_id": 99}) + + getmore_kwargs = {"name": "$cmd.aggregate"} if database_scope else {} + return execute_command( + collection, get_more_command(collection, opened["cursor"]["id"], **getmore_kwargs) + ) + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_EXPANDED_EVENT_TESTS)) +def test_changeStream_showExpandedEvents_event_gating( + collection, database_client, test_case: ChangeStreamGatingTestCase +): + """Test $changeStream showExpandedEvents gating of expanded DDL/index events.""" + result = _emit_ddl_result(collection, database_client, test_case) + assertProperties(result, test_case.expected, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_namespace_errors.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_namespace_errors.py new file mode 100644 index 000000000..b84800516 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_namespace_errors.py @@ -0,0 +1,141 @@ +"""Tests for $changeStream namespace-related rejections (view, reserved, cluster).""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + INVALID_NAMESPACE_ERROR, + INVALID_OPTIONS_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ExistingDatabase, ViewCollection + +# Property [View Namespace Rejection]: opening a stream on a view is rejected +# because change streams are not allowed on views. +CHANGESTREAM_VIEW_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view", + target_collection=ViewCollection(), + docs=[{"_id": 1}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {}, + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="$changeStream should reject opening a stream on a view", + ), +] + +# Property [Reserved Namespace Rejection]: opening a stream on a reserved +# database or a reserved system collection is rejected as an invalid namespace. +CHANGESTREAM_RESERVED_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "reserved_db_admin", + target_collection=ExistingDatabase(db_name="admin"), + docs=None, + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {}, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="$changeStream should reject a collection-scoped stream on the admin database", + ), + CommandTestCase( + "reserved_db_local", + target_collection=ExistingDatabase(db_name="local"), + docs=None, + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {}, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="$changeStream should reject a collection-scoped stream on the local database", + ), + CommandTestCase( + "reserved_db_config", + target_collection=ExistingDatabase(db_name="config"), + docs=None, + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {}, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="$changeStream should reject a collection-scoped stream on the config database", + ), + CommandTestCase( + "system_views", + docs=[{"_id": 1}], + command={"aggregate": "system.views", "pipeline": [{"$changeStream": {}}], "cursor": {}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="$changeStream should reject a stream on the system.views collection", + ), + CommandTestCase( + "system_profile", + docs=[{"_id": 1}], + command={"aggregate": "system.profile", "pipeline": [{"$changeStream": {}}], "cursor": {}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="$changeStream should reject a stream on the system.profile collection", + ), +] + +# Property [allChangesForCluster Namespace Rejection]: allChangesForCluster true +# is rejected as an invalid option anywhere other than a collection-less stream +# on the admin database, including a non-admin database and the admin database +# with a collection name present. +CHANGESTREAM_ALL_CHANGES_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_changes_true_non_admin", + docs=[{"_id": 1}], + command={ + "aggregate": 1, + "pipeline": [{"$changeStream": {"allChangesForCluster": True}}], + "cursor": {}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="$changeStream should reject allChangesForCluster true on a non-admin database", + ), + CommandTestCase( + "all_changes_true_admin_collection", + target_collection=ExistingDatabase(db_name="admin"), + docs=None, + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {"allChangesForCluster": True}}], + "cursor": {}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=( + "$changeStream should reject allChangesForCluster true on the admin" + " database with a collection name" + ), + ), +] + +CHANGESTREAM_NAMESPACE_ERROR_TESTS = ( + CHANGESTREAM_VIEW_NAMESPACE_ERROR_TESTS + + CHANGESTREAM_RESERVED_NAMESPACE_ERROR_TESTS + + CHANGESTREAM_ALL_CHANGES_NAMESPACE_ERROR_TESTS +) + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test", pytest_params(CHANGESTREAM_NAMESPACE_ERROR_TESTS)) +def test_changeStream_namespace_errors(database_client, collection, test): + """Test $changeStream rejects disallowed namespaces and option/namespace combinations.""" + target = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(target) + result = execute_command(target, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_namespace_scope.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_namespace_scope.py new file mode 100644 index 000000000..23e2b375e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_namespace_scope.py @@ -0,0 +1,98 @@ +"""Tests for $changeStream namespace scope (collection, database, cluster).""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.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.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ExistingDatabase, + TargetDatabase, +) + +# Property [Namespace Scope]: a stream opens on a collection-scoped, a +# database-scoped, and the cluster-wide namespace. +CHANGESTREAM_NAMESPACE_SCOPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collection_existing", + docs=[{"_id": 1}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open a collection-scoped stream on an existing collection", + ), + CommandTestCase( + "collection_nonexistent", + docs=None, + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open a collection-scoped stream on a non-existent collection", + ), + CommandTestCase( + "collection_capped", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$changeStream": {}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open a collection-scoped stream on a capped collection", + ), + CommandTestCase( + "database_existing", + docs=[{"_id": 1}], + command={"aggregate": 1, "pipeline": [{"$changeStream": {}}], "cursor": {}}, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open a database-scoped stream on an existing database", + ), + CommandTestCase( + "database_nonexistent", + target_collection=TargetDatabase(suffix="absent"), + docs=None, + command={"aggregate": 1, "pipeline": [{"$changeStream": {}}], "cursor": {}}, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open a database-scoped stream on a non-existent database", + ), + CommandTestCase( + "cluster_admin", + target_collection=ExistingDatabase(db_name="admin"), + docs=None, + command={ + "aggregate": 1, + "pipeline": [{"$changeStream": {"allChangesForCluster": True}}], + "cursor": {}, + }, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open a cluster-wide stream on the admin database", + ), +] + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test", pytest_params(CHANGESTREAM_NAMESPACE_SCOPE_TESTS)) +def test_changeStream_namespace_scope(database_client, collection, register_db_cleanup, test): + """Test $changeStream opens across collection, database, and cluster namespace scopes.""" + target = test.prepare(database_client, collection) + if isinstance(test.target_collection, TargetDatabase): + register_db_cleanup(target.database.name) + ctx = CommandContext.from_collection(target) + result = execute_command(target, test.build_command(ctx)) + assertResult(result, expected=test.build_expected(ctx), msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_resume.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_resume.py new file mode 100644 index 000000000..dfb4bc6f5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_resume.py @@ -0,0 +1,550 @@ +"""Tests for $changeStream resume-token repositioning behavior. + +Each behavior is written as a standalone, self-contained test. Each test +captures the token it needs inline, opens the resumed stream inline, and asserts +inline, so the precondition, action, and expectation are all visible in one +place. +""" + +from __future__ import annotations + +import pytest +from bson import Binary +from utils.changeStream_common import change_stream_command, get_more_command + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CHANGE_STREAM_FATAL_ERROR, + INVALID_RESUME_TOKEN_ERROR, + OVERFLOW_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Eq, Len + +pytestmark = [pytest.mark.requires(change_streams=True), pytest.mark.aggregate] + + +# Token capture helpers. A resume token must be captured from a real stream +# before it can be used, so each helper opens a stream, optionally seeds events, +# and returns a token from the response. + + +def _open_stream(collection, spec=None): + """Open a $changeStream and return the open response.""" + return execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": spec or {}}]), + ) + + +def _drain(collection, opened): + """Return the next batch of events from an already-open stream.""" + result = execute_command(collection, get_more_command(collection, opened["cursor"]["id"])) + return result["cursor"]["nextBatch"] + + +def _first_event_token(collection, seed, spec=None): + """Open a stream, seed events, and return the first event's resume token.""" + opened = _open_stream(collection, spec) + seed(collection) + return _drain(collection, opened)[0]["_id"] + + +def _event_token_of_type(collection, operation_type, seed, spec=None): + """Open a stream, seed events, and return the token of the first matching event.""" + opened = _open_stream(collection, spec) + seed(collection) + batch = _drain(collection, opened) + return next(e["_id"] for e in batch if e["operationType"] == operation_type) + + +def _high_water_mark(collection): + """Return the postBatchResumeToken of a freshly opened, idle stream.""" + return _open_stream(collection)["cursor"]["postBatchResumeToken"] + + +def _pre_oplog_token(collection): + """Return a token whose captured cluster time predates the oplog window. + + Rewrites the cluster-time seconds (four bytes after the version byte) of a + high-water-mark token's _data hex to a near-zero value. + """ + data = _high_water_mark(collection)["_data"] + return {"_data": data[:2] + "00000001" + data[10:]} + + +# Property [Resume After Event]: resumeAfter an event token delivers the events that follow it. +def test_resume_after_event_token(collection): + """Test $changeStream resumeAfter from an event token.""" + token = _first_event_token( + collection, lambda c: c.insert_many([{"_id": 1}, {"_id": 2}, {"_id": 3}]) + ) + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"resumeAfter": token}}]), + ) + + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(2)}, + "cursor.firstBatch.0.documentKey._id": Eq(2), + "cursor.firstBatch.1.documentKey._id": Eq(3), + }, + msg="$changeStream resumeAfter should resume after the captured event token", + raw_res=True, + ) + + +# Property [Start After Event]: startAfter an event token delivers the events that follow it. +def test_start_after_event_token(collection): + """Test $changeStream startAfter from an event token.""" + token = _first_event_token( + collection, lambda c: c.insert_many([{"_id": 1}, {"_id": 2}, {"_id": 3}]) + ) + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"startAfter": token}}]), + ) + + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(2)}, + "cursor.firstBatch.0.documentKey._id": Eq(2), + "cursor.firstBatch.1.documentKey._id": Eq(3), + }, + msg="$changeStream startAfter should resume after the captured event token", + raw_res=True, + ) + + +# Property [Resume After Drop]: resumeAfter a drop token positions the stream at the invalidate. +def test_resume_after_drop_token(collection): + """Test $changeStream resumeAfter from a drop-event token.""" + + def seed(c): + c.insert_one({"_id": 1}) + c.drop() + + token = _event_token_of_type(collection, "drop", seed) + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"resumeAfter": token}}]), + ) + + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(1)}, + "cursor.firstBatch.0.operationType": Eq("invalidate"), + }, + msg="$changeStream resumeAfter should accept a drop-event token, positioned at invalidate", + raw_res=True, + ) + + +# Property [Start After Drop]: startAfter a drop token positions the stream at the invalidate. +def test_start_after_drop_token(collection): + """Test $changeStream startAfter from a drop-event token.""" + + def seed(c): + c.insert_one({"_id": 1}) + c.drop() + + token = _event_token_of_type(collection, "drop", seed) + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"startAfter": token}}]), + ) + + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(1)}, + "cursor.firstBatch.0.operationType": Eq("invalidate"), + }, + msg="$changeStream startAfter should accept a drop-event token, positioned at invalidate", + raw_res=True, + ) + + +# Property [Resume Regular Under Expanded]: a regular-event token resumes under showExpandedEvents. +def test_resume_regular_token_under_show_expanded_events(collection): + """Test $changeStream resuming a regular-event token under showExpandedEvents true.""" + token = _first_event_token( + collection, lambda c: c.insert_many([{"_id": 1}, {"_id": 2}, {"_id": 3}]) + ) + + result = execute_command( + collection, + change_stream_command( + collection, + pipeline=[{"$changeStream": {"resumeAfter": token, "showExpandedEvents": True}}], + ), + ) + + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(2)}, + "cursor.firstBatch.0.documentKey._id": Eq(2), + "cursor.firstBatch.1.documentKey._id": Eq(3), + }, + msg="$changeStream should resume a regular-event token under showExpandedEvents true", + raw_res=True, + ) + + +# Property [Resume After Pre-Oplog]: resumeAfter a token whose cluster time +# predates the oplog window is accepted, opening from the earliest available +# event rather than being rejected. +def test_resume_after_pre_oplog_token(collection): + """Test $changeStream resumeAfter from a token predating the oplog window.""" + token = _pre_oplog_token(collection) + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"resumeAfter": token}}]), + ) + + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="$changeStream resumeAfter should accept a token predating the oplog window", + raw_res=True, + ) + + +# Property [Start After Pre-Oplog]: startAfter a token whose cluster time +# predates the oplog window is accepted, opening from the earliest available +# event rather than being rejected. +def test_start_after_pre_oplog_token(collection): + """Test $changeStream startAfter from a token predating the oplog window.""" + token = _pre_oplog_token(collection) + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"startAfter": token}}]), + ) + + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="$changeStream startAfter should accept a token predating the oplog window", + raw_res=True, + ) + + +# Property [Resume After Invalidate]: resumeAfter an invalidate token is rejected. +def test_resume_after_invalidate_token(collection): + """Test $changeStream resumeAfter from an invalidate-event token.""" + + def seed(c): + c.insert_one({"_id": 1}) + c.drop() + + token = _event_token_of_type(collection, "invalidate", seed) + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"resumeAfter": token}}]), + ) + + assertResult( + result, + error_code=INVALID_RESUME_TOKEN_ERROR, + msg="$changeStream resumeAfter should reject an invalidate-event token", + raw_res=True, + ) + + +# Property [Resume After Two-Byte Truncated]: resumeAfter a two-byte-truncated token fails. +def test_resume_after_two_byte_truncated_token(collection): + """Test $changeStream resumeAfter from a two-byte-truncated token.""" + token = dict(_first_event_token(collection, lambda c: c.insert_one({"_id": 1}))) + # One byte is tolerated; two bytes leave the KeyString unable to decode. + token["_data"] = token["_data"][:-4] + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"resumeAfter": token}}]), + ) + + assertResult( + result, + error_code=OVERFLOW_ERROR, + msg="$changeStream resumeAfter should reject a two-byte-truncated token as overflow", + raw_res=True, + ) + + +# Property [Start After Two-Byte Truncated]: startAfter a two-byte-truncated token fails. +def test_start_after_two_byte_truncated_token(collection): + """Test $changeStream startAfter from a two-byte-truncated token.""" + token = dict(_first_event_token(collection, lambda c: c.insert_one({"_id": 1}))) + token["_data"] = token["_data"][:-4] + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"startAfter": token}}]), + ) + + assertResult( + result, + error_code=OVERFLOW_ERROR, + msg="$changeStream startAfter should reject a two-byte-truncated token as overflow", + raw_res=True, + ) + + +# Property [Start After Invalidate Advances]: startAfter an invalidate delivers later events. +def test_start_after_resumes_after_invalidate(collection): + """Test $changeStream startAfter past an invalidate event.""" + + def seed(c): + c.insert_one({"_id": 1}) + c.drop() + + token = _event_token_of_type(collection, "invalidate", seed) + + opened = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"startAfter": token}}]), + ) + collection.insert_one({"_id": 2}) + result = execute_command(collection, get_more_command(collection, opened["cursor"]["id"])) + + assertResult( + result, + expected={ + "cursor": {"nextBatch": Len(1)}, + "cursor.nextBatch.0.operationType": Eq("insert"), + "cursor.nextBatch.0.documentKey._id": Eq(2), + }, + msg="$changeStream startAfter should deliver events after an invalidate", + raw_res=True, + ) + + +# Property [Expanded-Only Token Fails Advance]: an expanded-only token fails to advance unexpanded. +def test_expanded_only_token_fails_on_advance(collection): + """Test $changeStream resumeAfter from an expanded-only event token without expansion.""" + + def seed(c): + c.insert_one({"_id": 0}) + execute_command(c, {"createIndexes": c.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]}) + + token = _event_token_of_type( + collection, "createIndexes", seed, spec={"showExpandedEvents": True} + ) + + opened = execute_command( + collection, + change_stream_command( + collection, + pipeline=[{"$changeStream": {"resumeAfter": token, "showExpandedEvents": False}}], + ), + ) + collection.insert_one({"_id": 1}) + result = execute_command(collection, get_more_command(collection, opened["cursor"]["id"])) + + assertResult( + result, + error_code=CHANGE_STREAM_FATAL_ERROR, + msg="$changeStream should fail to advance past an expanded-only token without expansion", + raw_res=True, + ) + + +# Property [Resume After Foreign Token]: resumeAfter a foreign-collection token fails to advance. +def test_resume_after_foreign_collection_token(collection, database_client): + """Test $changeStream resumeAfter from a foreign-collection token.""" + other = database_client[f"{collection.name}_other"] + database_client.create_collection(other.name) + token = _first_event_token(other, lambda c: c.insert_one({"_id": 1})) + + opened = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"resumeAfter": token}}]), + ) + collection.insert_one({"_id": 99}) + result = execute_command(collection, get_more_command(collection, opened["cursor"]["id"])) + + assertResult( + result, + error_code=CHANGE_STREAM_FATAL_ERROR, + msg="$changeStream resumeAfter should fail to advance past a foreign-collection token", + raw_res=True, + ) + + +# Property [Start After Foreign Token]: startAfter a foreign-collection token fails to advance. +def test_start_after_foreign_collection_token(collection, database_client): + """Test $changeStream startAfter from a foreign-collection token.""" + other = database_client[f"{collection.name}_other"] + database_client.create_collection(other.name) + token = _first_event_token(other, lambda c: c.insert_one({"_id": 1})) + + opened = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"startAfter": token}}]), + ) + collection.insert_one({"_id": 99}) + result = execute_command(collection, get_more_command(collection, opened["cursor"]["id"])) + + assertResult( + result, + error_code=CHANGE_STREAM_FATAL_ERROR, + msg="$changeStream startAfter should fail to advance past a foreign-collection token", + raw_res=True, + ) + + +def _resume_after_mutated(collection, mutate): + """Capture a first-event token, apply ``mutate``, and resume from it. + + The tolerance tests mutate the token in ways that do not change its + interpreted position, so each resume should behave identically to resuming + from the unmutated token. + """ + token = dict( + _first_event_token( + collection, lambda c: c.insert_many([{"_id": 1}, {"_id": 2}, {"_id": 3}]) + ) + ) + mutated = mutate(token) + return execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {"resumeAfter": mutated}}]), + ) + + +# Property [Tolerance Lowercase Hex]: a lower-cased _data hex resumes identically. +def test_tolerance_lowercase_hex(collection): + """Test $changeStream resumeAfter from a lower-cased _data token.""" + result = _resume_after_mutated(collection, lambda t: {**t, "_data": t["_data"].lower()}) + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(2)}, + "cursor.firstBatch.0.documentKey._id": Eq(2), + "cursor.firstBatch.1.documentKey._id": Eq(3), + }, + msg="$changeStream resumeAfter should resume identically when the token is lower-cased hex", + raw_res=True, + ) + + +# Property [Tolerance Truncate One Byte]: a one-byte-truncated _data resumes identically. +def test_tolerance_truncate_one_byte(collection): + """Test $changeStream resumeAfter from a one-byte-truncated token.""" + result = _resume_after_mutated(collection, lambda t: {**t, "_data": t["_data"][:-2]}) + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(2)}, + "cursor.firstBatch.0.documentKey._id": Eq(2), + "cursor.firstBatch.1.documentKey._id": Eq(3), + }, + msg="$changeStream resumeAfter should resume identically when the token is truncated by" + " one byte", + raw_res=True, + ) + + +# Property [Tolerance Append FF]: a _data extended with an FF byte resumes identically. +def test_tolerance_append_ff_byte(collection): + """Test $changeStream resumeAfter from a token extended with an FF byte.""" + result = _resume_after_mutated(collection, lambda t: {**t, "_data": t["_data"] + "FF"}) + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(2)}, + "cursor.firstBatch.0.documentKey._id": Eq(2), + "cursor.firstBatch.1.documentKey._id": Eq(3), + }, + msg="$changeStream resumeAfter should resume identically when the token is extended with an" + " FF byte", + raw_res=True, + ) + + +# Property [Tolerance Append 00]: a _data extended with a 00 byte resumes identically. +def test_tolerance_append_00_byte(collection): + """Test $changeStream resumeAfter from a token extended with a 00 byte.""" + result = _resume_after_mutated(collection, lambda t: {**t, "_data": t["_data"] + "00"}) + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(2)}, + "cursor.firstBatch.0.documentKey._id": Eq(2), + "cursor.firstBatch.1.documentKey._id": Eq(3), + }, + msg="$changeStream resumeAfter should resume identically when the token is extended with a" + " 00 byte", + raw_res=True, + ) + + +# Property [Tolerance Extra Fields]: a token with unknown extra fields resumes identically. +def test_tolerance_extra_fields(collection): + """Test $changeStream resumeAfter from a token with unknown extra fields.""" + result = _resume_after_mutated(collection, lambda t: {**t, "unknownField": 1, "another": "x"}) + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(2)}, + "cursor.firstBatch.0.documentKey._id": Eq(2), + "cursor.firstBatch.1.documentKey._id": Eq(3), + }, + msg="$changeStream resumeAfter should resume identically when the token is augmented with" + " unknown fields", + raw_res=True, + ) + + +# Property [Tolerance Empty TypeBits]: a token with an empty _typeBits binary resumes identically. +def test_tolerance_empty_typebits(collection): + """Test $changeStream resumeAfter from a token with an empty _typeBits binary.""" + result = _resume_after_mutated(collection, lambda t: {**t, "_typeBits": Binary(b"\x00")}) + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(2)}, + "cursor.firstBatch.0.documentKey._id": Eq(2), + "cursor.firstBatch.1.documentKey._id": Eq(3), + }, + msg="$changeStream resumeAfter should resume identically when the token is given an empty" + " _typeBits binary", + raw_res=True, + ) + + +# Property [High-Water-Mark Resume]: a high-water-mark token resumes, delivering later events. +@pytest.mark.parametrize("option", ["resumeAfter", "startAfter"]) +def test_resume_from_high_water_mark(collection, option): + """Test $changeStream resuming from a high-water-mark token.""" + mark = _high_water_mark(collection) + collection.insert_many([{"_id": 1}, {"_id": 2}, {"_id": 3}]) + + result = execute_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": {option: mark}}]), + ) + + assertResult( + result, + expected={ + "cursor": {"firstBatch": Len(3)}, + "cursor.firstBatch.0.documentKey._id": Eq(1), + "cursor.firstBatch.1.documentKey._id": Eq(2), + "cursor.firstBatch.2.documentKey._id": Eq(3), + }, + msg=f"$changeStream {option!r} should resume from a high-water-mark token", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_resume_mutual_exclusivity.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_resume_mutual_exclusivity.py new file mode 100644 index 000000000..52528e793 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_resume_mutual_exclusivity.py @@ -0,0 +1,66 @@ +"""Tests for $changeStream mutual exclusivity of resume options.""" + +from __future__ import annotations + +import pytest +from utils.changeStream_common import OPERATION_TIME, RESUME_TOKEN, change_stream_command + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + MULTIPLE_RESUME_OPTIONS_ERROR, + RESUME_AFTER_START_AFTER_CONFLICT_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Resume-Option Mutual Exclusivity]: specifying any pair of resume +# options in a single spec is rejected at open; the conflict is a parse-time +# check, so the same token may fill both token options. +CHANGESTREAM_RESUME_MUTUAL_EXCLUSIVITY_TESTS: list[StageTestCase] = [ + StageTestCase( + "resume_after_and_start_after", + pipeline=[{"$changeStream": {"resumeAfter": RESUME_TOKEN, "startAfter": RESUME_TOKEN}}], + error_code=RESUME_AFTER_START_AFTER_CONFLICT_ERROR, + msg="$changeStream should reject both resumeAfter and startAfter together", + ), + StageTestCase( + "resume_after_and_operation_time", + pipeline=[ + {"$changeStream": {"resumeAfter": RESUME_TOKEN, "startAtOperationTime": OPERATION_TIME}} + ], + error_code=MULTIPLE_RESUME_OPTIONS_ERROR, + msg="$changeStream should reject both resumeAfter and startAtOperationTime together", + ), + StageTestCase( + "start_after_and_operation_time", + pipeline=[ + {"$changeStream": {"startAfter": RESUME_TOKEN, "startAtOperationTime": OPERATION_TIME}} + ], + error_code=MULTIPLE_RESUME_OPTIONS_ERROR, + msg="$changeStream should reject both startAfter and startAtOperationTime together", + ), +] + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_RESUME_MUTUAL_EXCLUSIVITY_TESTS)) +def test_changeStream_resume_option_mutual_exclusivity(collection, test_case): + """Test $changeStream rejects specifying more than one resume option.""" + opened = execute_command( + collection, change_stream_command(collection, pipeline=[{"$changeStream": {}}]) + ) + token = opened["cursor"]["postBatchResumeToken"] + operation_time = opened["operationTime"] + substitutions = {id(RESUME_TOKEN): token, id(OPERATION_TIME): operation_time} + spec = { + key: substitutions.get(id(value), value) + for key, value in test_case.pipeline[0]["$changeStream"].items() + } + result = execute_command( + collection, change_stream_command(collection, pipeline=[{"$changeStream": spec}]) + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_spec_acceptance.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_spec_acceptance.py new file mode 100644 index 000000000..d30312287 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_spec_acceptance.py @@ -0,0 +1,227 @@ +"""Tests for $changeStream spec and option acceptance for valid specs.""" + +from __future__ import annotations + +import pytest +from utils.changeStream_common import change_stream_command + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Empty Spec Acceptance]: an empty spec document opens a change stream +# because every option field is optional. +CHANGESTREAM_EMPTY_SPEC_TESTS: list[StageTestCase] = [ + StageTestCase( + "empty_spec", + pipeline=[{"$changeStream": {}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should open with an empty spec document", + ), +] + +# Property [Boolean Option Acceptance]: showExpandedEvents accepts both boolean +# values, and allChangesForCluster accepts false on a collection-scoped stream. +# allChangesForCluster true is not accepted here because it requires a +# cluster-scoped stream on the admin database; that accept path is covered by +# the namespace-scope tests. +CHANGESTREAM_BOOLEAN_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "all_changes_for_cluster_false", + pipeline=[{"$changeStream": {"allChangesForCluster": False}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept allChangesForCluster false", + ), + StageTestCase( + "show_expanded_events_true", + pipeline=[{"$changeStream": {"showExpandedEvents": True}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept showExpandedEvents true", + ), + StageTestCase( + "show_expanded_events_false", + pipeline=[{"$changeStream": {"showExpandedEvents": False}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept showExpandedEvents false", + ), +] + +# Property [fullDocument Enum Acceptance]: fullDocument accepts each of its +# documented enum strings. +CHANGESTREAM_FULL_DOCUMENT_TESTS: list[StageTestCase] = [ + StageTestCase( + f"full_document_{value}", + pipeline=[{"$changeStream": {"fullDocument": value}}], + expected={"ok": Eq(1.0)}, + msg=f"$changeStream should accept fullDocument '{value}'", + ) + for value in ["default", "required", "updateLookup", "whenAvailable"] +] + +# Property [fullDocumentBeforeChange Enum Acceptance]: fullDocumentBeforeChange +# accepts each of its documented enum strings. +CHANGESTREAM_FULL_DOCUMENT_BEFORE_CHANGE_TESTS: list[StageTestCase] = [ + StageTestCase( + f"full_document_before_change_{value}", + pipeline=[{"$changeStream": {"fullDocumentBeforeChange": value}}], + expected={"ok": Eq(1.0)}, + msg=f"$changeStream should accept fullDocumentBeforeChange '{value}'", + ) + for value in ["off", "whenAvailable", "required"] +] + +# Property [Option Combination]: independent option fields combine in one spec +# without a parse-time conflict, and the two enum options are parsed +# independently of each other. +CHANGESTREAM_OPTION_COMBINATION_TESTS: list[StageTestCase] = [ + StageTestCase( + "combo_all_options", + pipeline=[ + { + "$changeStream": { + "allChangesForCluster": False, + "showExpandedEvents": True, + "fullDocument": "updateLookup", + "fullDocumentBeforeChange": "whenAvailable", + } + } + ], + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept all independent options combined in one spec", + ), +] + [ + StageTestCase( + f"combo_{full_document}_{before_change}", + pipeline=[ + { + "$changeStream": { + "fullDocument": full_document, + "fullDocumentBeforeChange": before_change, + } + } + ], + expected={"ok": Eq(1.0)}, + msg=( + f"$changeStream should accept fullDocument '{full_document}' with " + f"fullDocumentBeforeChange '{before_change}'" + ), + ) + for full_document, before_change in [ + ("default", "off"), + ("required", "required"), + ("whenAvailable", "whenAvailable"), + ] +] + +# Property [Null Option Acceptance]: an explicit null value for any of the five +# non-boolean-typed options (fullDocument, fullDocumentBeforeChange, +# resumeAfter, startAfter, startAtOperationTime) is accepted and opens a stream +# as if the field were unset, and a null resume option does not trigger +# resume-option mutual exclusivity. This group exercises only the null value; +# rejection of other BSON types per option is covered in +# test_changeStream_validation_errors.py. +CHANGESTREAM_NULL_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "null_full_document", + pipeline=[{"$changeStream": {"fullDocument": None}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept a null fullDocument", + ), + StageTestCase( + "null_full_document_before_change", + pipeline=[{"$changeStream": {"fullDocumentBeforeChange": None}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept a null fullDocumentBeforeChange", + ), + StageTestCase( + "null_resume_after", + pipeline=[{"$changeStream": {"resumeAfter": None}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept a null resumeAfter", + ), + StageTestCase( + "null_start_after", + pipeline=[{"$changeStream": {"startAfter": None}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept a null startAfter", + ), + StageTestCase( + "null_start_at_operation_time", + pipeline=[{"$changeStream": {"startAtOperationTime": None}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should accept a null startAtOperationTime", + ), + StageTestCase( + "null_all_resume_options", + pipeline=[ + { + "$changeStream": { + "resumeAfter": None, + "startAfter": None, + "startAtOperationTime": None, + } + } + ], + expected={"ok": Eq(1.0)}, + msg="$changeStream should not treat null resume options as a mutual exclusivity conflict", + ), +] + +CHANGESTREAM_SUCCESS_TESTS = ( + CHANGESTREAM_EMPTY_SPEC_TESTS + + CHANGESTREAM_BOOLEAN_OPTION_TESTS + + CHANGESTREAM_FULL_DOCUMENT_TESTS + + CHANGESTREAM_FULL_DOCUMENT_BEFORE_CHANGE_TESTS + + CHANGESTREAM_OPTION_COMBINATION_TESTS + + CHANGESTREAM_NULL_OPTION_TESTS +) + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_SUCCESS_TESTS)) +def test_changeStream_cases(collection, test_case: StageTestCase): + """Test $changeStream spec document and option acceptance.""" + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) + + +# Property [Null Resume-Option Non-Conflict]: a resume option set to an explicit +# null does not count as a second resume point, so pairing it with another +# present resume token opens successfully rather than tripping resume-option +# mutual exclusivity. This holds for every (present token, null option) pairing. +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize( + "present_field,null_field", + [ + ("resumeAfter", "startAtOperationTime"), + ("startAfter", "startAtOperationTime"), + ("resumeAfter", "startAfter"), + ("startAfter", "resumeAfter"), + ], +) +def test_changeStream_null_resume_option_non_conflict(collection, present_field, null_field): + """Test $changeStream treats a null resume option as absent, not a second resume point.""" + opened = execute_command( + collection, change_stream_command(collection, pipeline=[{"$changeStream": {}}]) + ) + token = opened["cursor"]["postBatchResumeToken"] + + spec = {present_field: token, null_field: None} + result = execute_command( + collection, change_stream_command(collection, pipeline=[{"$changeStream": spec}]) + ) + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg=f"$changeStream should accept {present_field!r} token with null {null_field!r}", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_stable_api.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_stable_api.py new file mode 100644 index 000000000..d77cb152d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_stable_api.py @@ -0,0 +1,88 @@ +"""Tests for $changeStream behavior under Stable API v1 (apiStrict).""" + +from __future__ import annotations + +import pytest +from utils.changeStream_common import OPERATION_TIME, change_stream_command + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import API_STRICT_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 [Stable API V1 Acceptance]: under apiStrict true (Stable API V1), a +# spec that omits showExpandedEvents opens the stream; specifying +# showExpandedEvents under apiStrict true is rejected because the parameter is +# not part of API Version 1, regardless of its boolean value. +CHANGESTREAM_STABLE_API_V1_TESTS: list[StageTestCase] = [ + StageTestCase( + "stable_api_empty_spec", + pipeline=[{"$changeStream": {}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should open with an empty spec under apiStrict true", + ), + StageTestCase( + "stable_api_full_document", + pipeline=[{"$changeStream": {"fullDocument": "updateLookup"}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should open with fullDocument under apiStrict true", + ), + StageTestCase( + "stable_api_full_document_before_change", + pipeline=[{"$changeStream": {"fullDocumentBeforeChange": "whenAvailable"}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should open with fullDocumentBeforeChange under apiStrict true", + ), + StageTestCase( + "stable_api_start_at_operation_time", + pipeline=[{"$changeStream": {"startAtOperationTime": OPERATION_TIME}}], + expected={"ok": Eq(1.0)}, + msg="$changeStream should open with startAtOperationTime under apiStrict true", + ), + StageTestCase( + "stable_api_show_expanded_events_true", + pipeline=[{"$changeStream": {"showExpandedEvents": True}}], + error_code=API_STRICT_ERROR, + msg="$changeStream should reject showExpandedEvents true under apiStrict true", + ), + StageTestCase( + "stable_api_show_expanded_events_false", + pipeline=[{"$changeStream": {"showExpandedEvents": False}}], + error_code=API_STRICT_ERROR, + msg="$changeStream should reject showExpandedEvents false under apiStrict true", + ), +] + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_STABLE_API_V1_TESTS)) +def test_changeStream_stable_api_v1(collection, test_case): + """Test $changeStream Stable API V1 (apiStrict true) acceptance and rejection.""" + stage = dict(test_case.pipeline[0]["$changeStream"]) + if stage.get("startAtOperationTime") is OPERATION_TIME: + base = execute_command( + collection, change_stream_command(collection, pipeline=[{"$changeStream": {}}]) + ) + stage["startAtOperationTime"] = base["operationTime"] + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$changeStream": stage}], + "cursor": {}, + "apiVersion": "1", + "apiStrict": True, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py new file mode 100644 index 000000000..e136e6009 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py @@ -0,0 +1,117 @@ +"""Tests for $changeStream startAtOperationTime boundary across scopes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest +from bson import Timestamp +from utils.changeStream_common import change_stream_command + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class ChangeStreamTimestampTestCase(BaseTestCase): + """Test case for opening a $changeStream at a startAtOperationTime boundary. + + Attributes: + compute_start: Receives the current operationTime captured at run time + and returns the timestamp to pass as startAtOperationTime, so the + boundary is expressed relative to the live oplog rather than as a + static value + """ + + compute_start: Any = None + + +# Property [Timestamp Start Boundary]: a startAtOperationTime at the current +# operationTime, at a zero-increment timestamp for the current second, and at a +# far-future timestamp each opens the stream, identically across collection-, +# database-, and cluster-scoped streams. +CHANGESTREAM_TIMESTAMP_BOUNDARY_TESTS: list[ChangeStreamTimestampTestCase] = [ + ChangeStreamTimestampTestCase( + "current", + compute_start=lambda operation_time: operation_time, + expected={"ok": Eq(1.0)}, + msg="$changeStream should open at the current operationTime", + ), + ChangeStreamTimestampTestCase( + "zero_increment", + compute_start=lambda operation_time: Timestamp(operation_time.time, 0), + expected={"ok": Eq(1.0)}, + msg="$changeStream should open at a current-second zero-increment timestamp", + ), + ChangeStreamTimestampTestCase( + "future", + compute_start=lambda operation_time: Timestamp(operation_time.time + 1_000_000, 0), + expected={"ok": Eq(1.0)}, + msg="$changeStream should open at a far-future timestamp", + ), +] + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_TIMESTAMP_BOUNDARY_TESTS)) +def test_changeStream_timestamp_boundary_collection_scope( + collection, test_case: ChangeStreamTimestampTestCase +): + """Test $changeStream opens a collection-scoped stream at startAtOperationTime boundaries.""" + base = execute_command( + collection, change_stream_command(collection, pipeline=[{"$changeStream": {}}]) + ) + start = test_case.compute_start(base["operationTime"]) + result = execute_command( + collection, + change_stream_command( + collection, pipeline=[{"$changeStream": {"startAtOperationTime": start}}] + ), + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_TIMESTAMP_BOUNDARY_TESTS)) +def test_changeStream_timestamp_boundary_database_scope( + collection, test_case: ChangeStreamTimestampTestCase +): + """Test $changeStream opens a database-scoped stream at startAtOperationTime boundaries.""" + base = execute_command( + collection, change_stream_command(collection, pipeline=[{"$changeStream": {}}]) + ) + start = test_case.compute_start(base["operationTime"]) + result = execute_command( + collection, + change_stream_command( + collection, + pipeline=[{"$changeStream": {"startAtOperationTime": start}}], + aggregate=1, + ), + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_TIMESTAMP_BOUNDARY_TESTS)) +def test_changeStream_timestamp_boundary_cluster_scope( + collection, test_case: ChangeStreamTimestampTestCase +): + """Test $changeStream opens a cluster-wide stream at startAtOperationTime boundaries.""" + base = execute_command( + collection, change_stream_command(collection, pipeline=[{"$changeStream": {}}]) + ) + start = test_case.compute_start(base["operationTime"]) + spec = {"startAtOperationTime": start, "allChangesForCluster": True} + result = execute_admin_command( + collection, + change_stream_command(collection, pipeline=[{"$changeStream": spec}], aggregate=1), + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_validation_errors.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_validation_errors.py new file mode 100644 index 000000000..0a42fc7bd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_validation_errors.py @@ -0,0 +1,530 @@ +"""Tests for $changeStream spec validation errors (malformed specs and option values).""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, DBRef, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + CHANGE_STREAM_SPEC_NOT_OBJECT_ERROR, + FAILED_TO_PARSE_ERROR, + KEYSTRING_UNKNOWN_TYPE_ERROR, + RESUME_TOKEN_EMPTY_ERROR, + RESUME_TOKEN_MALFORMED_ERROR, + RESUME_TOKEN_TYPEBITS_WRONG_TYPE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Spec-Level Non-Object Rejection]: a spec value of any non-object +# BSON type, including null and an array, produces an error; an array spec is +# not unwrapped into its elements. +CHANGESTREAM_SPEC_NON_OBJECT_TESTS: list[StageTestCase] = [ + StageTestCase( + f"spec_non_object_{tid}", + pipeline=[{"$changeStream": val}], + error_code=CHANGE_STREAM_SPEC_NOT_OBJECT_ERROR, + msg=f"$changeStream should reject a {tid} spec as not a nested object", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "x"), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("null", None), + ("empty_array", []), + ("array_of_spec", [{"fullDocument": "default"}]), + ] +] + +# Property [Spec-Level Unknown Field Rejection]: an option field name that is +# not a recognized option produces an unknown-field error; option names are +# case-sensitive, are not trimmed, and a DBRef spec is treated as a nested +# object whose reserved keys are unrecognized fields. +CHANGESTREAM_SPEC_UNKNOWN_FIELD_TESTS: list[StageTestCase] = [ + StageTestCase( + "unknown_field", + pipeline=[{"$changeStream": {"bogus": 1}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$changeStream should reject an unknown option field", + ), + StageTestCase( + "wrong_case_capitalized", + pipeline=[{"$changeStream": {"FullDocument": "default"}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$changeStream should reject a capitalized option name as unknown", + ), + StageTestCase( + "wrong_case_upper", + pipeline=[{"$changeStream": {"FULLDOCUMENT": "default"}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$changeStream should reject an upper-case option name as unknown", + ), + StageTestCase( + "wrong_case_lower", + pipeline=[{"$changeStream": {"fulldocument": "default"}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$changeStream should reject a lower-case option name as unknown", + ), + StageTestCase( + "leading_whitespace", + pipeline=[{"$changeStream": {" fullDocument": "default"}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$changeStream should reject an option name with leading whitespace as unknown", + ), + StageTestCase( + "dollar_prefixed_name", + pipeline=[{"$changeStream": {"$fullDocument": "default"}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$changeStream should reject a dollar-prefixed option name as unknown", + ), + StageTestCase( + "empty_name", + pipeline=[{"$changeStream": {"": "x"}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$changeStream should reject an empty option name as unknown", + ), + StageTestCase( + "dbref_object", + pipeline=[{"$changeStream": DBRef("c", 1)}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$changeStream should treat a DBRef spec as an object with unknown fields", + ), +] + +# Property [Expression Arguments Are Not Evaluated]: no option value is +# evaluated as an aggregation expression; an expression-shaped value is read as +# literal BSON at parse time and falls through to the type/enum/token validation +# for its literal shape rather than producing its evaluated result. +CHANGESTREAM_EXPRESSION_NOT_EVALUATED_TESTS: list[StageTestCase] = [ + StageTestCase( + "all_changes_for_cluster_literal_true", + pipeline=[{"$changeStream": {"allChangesForCluster": {"$literal": True}}}], + error_code=TYPE_MISMATCH_ERROR, + msg=( + "$changeStream should reject an expression object for" + " allChangesForCluster as wrong type" + ), + ), + StageTestCase( + "show_expanded_events_cond", + pipeline=[{"$changeStream": {"showExpandedEvents": {"$cond": [True, True, False]}}}], + error_code=TYPE_MISMATCH_ERROR, + msg="$changeStream should reject an expression object for showExpandedEvents as wrong type", + ), + StageTestCase( + "full_document_literal", + pipeline=[{"$changeStream": {"fullDocument": {"$literal": "updateLookup"}}}], + error_code=TYPE_MISMATCH_ERROR, + msg="$changeStream should reject an expression object for fullDocument as wrong type", + ), + StageTestCase( + "full_document_before_change_concat", + pipeline=[ + {"$changeStream": {"fullDocumentBeforeChange": {"$concat": ["when", "Available"]}}} + ], + error_code=TYPE_MISMATCH_ERROR, + msg=( + "$changeStream should reject an expression object for" + " fullDocumentBeforeChange as wrong type" + ), + ), + StageTestCase( + "start_at_operation_time_literal", + pipeline=[{"$changeStream": {"startAtOperationTime": {"$literal": Timestamp(1, 1)}}}], + error_code=TYPE_MISMATCH_ERROR, + msg=( + "$changeStream should reject an expression object for" + " startAtOperationTime as wrong type" + ), + ), + StageTestCase( + "resume_after_literal", + pipeline=[{"$changeStream": {"resumeAfter": {"$literal": {"_data": "8264000000"}}}}], + error_code=RESUME_TOKEN_MALFORMED_ERROR, + msg="$changeStream should reject an expression object for resumeAfter as a malformed token", + ), + StageTestCase( + "start_after_concat", + pipeline=[{"$changeStream": {"startAfter": {"$concat": ["8264", "000000"]}}}], + error_code=RESUME_TOKEN_MALFORMED_ERROR, + msg="$changeStream should reject an expression object for startAfter as a malformed token", + ), + StageTestCase( + "full_document_field_path", + pipeline=[{"$changeStream": {"fullDocument": "$someField"}}], + error_code=BAD_VALUE_ERROR, + msg=( + "$changeStream should reject a field-path string for" + " fullDocument as an invalid enum value" + ), + ), + StageTestCase( + "full_document_variable", + pipeline=[{"$changeStream": {"fullDocument": "$$ROOT"}}], + error_code=BAD_VALUE_ERROR, + msg=( + "$changeStream should reject a variable string for" + " fullDocument as an invalid enum value" + ), + ), + StageTestCase( + "full_document_before_change_field_path", + pipeline=[{"$changeStream": {"fullDocumentBeforeChange": "$someField"}}], + error_code=BAD_VALUE_ERROR, + msg=( + "$changeStream should reject a field-path string for" + " fullDocumentBeforeChange as an invalid enum value" + ), + ), +] + +# Property [Boolean Option Type Rejection]: each boolean option +# (allChangesForCluster, showExpandedEvents) rejects any non-boolean BSON type +# with a TypeMismatch error; there is no coercion from numbers, numeric +# strings, arrays, objects, or any other type to a boolean. +CHANGESTREAM_BOOLEAN_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"bool_type_{opt_id}_{tid}", + pipeline=[{"$changeStream": {opt: val}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$changeStream should reject a non-boolean value for {opt}", + ) + for opt, opt_id in [ + ("allChangesForCluster", "all_changes"), + ("showExpandedEvents", "show_expanded"), + ] + for tid, val in [ + ("int32_zero", 0), + ("int32_one", 1), + ("double_zero", DOUBLE_ZERO), + ("double_one", 1.0), + ("int64", Int64(1)), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("nan", FLOAT_NAN), + ("positive_infinity", FLOAT_INFINITY), + ("negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("string_true", "true"), + ("string_false", "false"), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + # DBRef encodes as a BSON object and is rejected as a non-boolean object. + ("dbref", DBRef("c", 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("empty_array", []), + ("array_true", [True]), + ("array_false", [False]), + ("empty_object", {}), + ] +] + +# Property [Boolean Option Null Rejection]: each boolean option rejects an +# explicit null with a TypeMismatch error, so only field omission yields the +# default; this contrasts with the five non-boolean options that accept null as +# unset. +CHANGESTREAM_BOOLEAN_NULL_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"bool_null_{opt_id}", + pipeline=[{"$changeStream": {opt: None}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$changeStream should reject an explicit null for {opt}", + ) + for opt, opt_id in [ + ("allChangesForCluster", "all_changes"), + ("showExpandedEvents", "show_expanded"), + ] +] + +# Property [String Enum Option Type Rejection]: each string-enum option +# (fullDocument, fullDocumentBeforeChange) rejects any non-string, non-null BSON +# type with a TypeMismatch error; an array is not unwrapped to its element. +CHANGESTREAM_ENUM_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"enum_type_{opt_id}_{tid}", + pipeline=[{"$changeStream": {opt: val}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$changeStream should reject a non-string value for {opt}", + ) + for opt, opt_id in [ + ("fullDocument", "full_document"), + ("fullDocumentBeforeChange", "before_change"), + ] + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + # DBRef encodes as a BSON object and is rejected as a non-string object. + ("dbref", DBRef("c", 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("empty_object", {}), + ("object", {"a": 1}), + ("empty_array", []), + ("array_single", ["default"]), + ("array_multi", ["default", "off"]), + ("array_nested", [["default"]]), + ] +] + +# Property [String Enum Option Value Rejection]: a string that is not a member +# of the option's enum set produces a BadValue error; comparison is exact, with +# no case folding, whitespace trimming, NUL truncation, Unicode normalization, +# or stripping of invisible or marker characters, and a sibling option's value +# or a dollar-prefixed string is treated as a plain invalid enum string. +CHANGESTREAM_ENUM_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"enum_value_{opt_id}_{suffix}", + pipeline=[{"$changeStream": {opt: val}}], + error_code=BAD_VALUE_ERROR, + msg=f"$changeStream should reject an invalid {opt} enum string", + ) + for opt, opt_id, suffix, val in [ + ("fullDocument", "full_document", "empty", ""), + ("fullDocument", "full_document", "sibling_off", "off"), + ("fullDocument", "full_document", "case_capitalized", "Default"), + ("fullDocument", "full_document", "case_upper", "DEFAULT"), + ("fullDocument", "full_document", "case_update_lookup", "UpdateLookup"), + ("fullDocument", "full_document", "leading_space", " default"), + ("fullDocument", "full_document", "trailing_space", "default "), + ("fullDocument", "full_document", "trailing_tab", "default\t"), + ("fullDocument", "full_document", "trailing_cr", "default\r"), + ("fullDocument", "full_document", "trailing_lf", "updateLookup\n"), + ("fullDocument", "full_document", "interior_space", "when available"), + ("fullDocument", "full_document", "trailing_nul", "default\x00"), + ("fullDocument", "full_document", "leading_nul", "\x00default"), + ("fullDocument", "full_document", "interior_nul", "default\x00default"), + # Fullwidth latin small d (U+FF44) in place of the ASCII d. + ("fullDocument", "full_document", "fullwidth_d", "\uff44efault"), + # Fullwidth latin small d (U+FF44) in place of the trailing ASCII d. + ("fullDocument", "full_document", "fullwidth_require_d", "require\uff44"), + # No-break space (U+00A0) prefix. + ("fullDocument", "full_document", "nbsp", "\u00a0default"), + # En space (U+2000) prefix. + ("fullDocument", "full_document", "en_space", "\u2000default"), + # Em space (U+2003) suffix. + ("fullDocument", "full_document", "em_space", "default\u2003"), + # Byte order mark (U+FEFF) prefix. + ("fullDocument", "full_document", "bom", "\ufeffdefault"), + # Zero-width space (U+200B) prefix. + ("fullDocument", "full_document", "zwsp", "\u200bdefault"), + # Zero-width joiner (U+200D) prefix. + ("fullDocument", "full_document", "zwj", "\u200ddefault"), + # Left-to-right mark (U+200E) prefix. + ("fullDocument", "full_document", "ltr_mark", "\u200edefault"), + # Right-to-left mark (U+200F) prefix. + ("fullDocument", "full_document", "rtl_mark", "\u200fdefault"), + ("fullDocument", "full_document", "dollar", "$"), + ("fullDocument", "full_document", "double_dollar", "$$"), + ("fullDocument", "full_document", "dollar_value", "$default"), + ("fullDocument", "full_document", "double_dollar_value", "$$default"), + ("fullDocumentBeforeChange", "before_change", "empty", ""), + ("fullDocumentBeforeChange", "before_change", "sibling_default", "default"), + ("fullDocumentBeforeChange", "before_change", "sibling_update_lookup", "updateLookup"), + ("fullDocumentBeforeChange", "before_change", "case_capitalized", "Off"), + ("fullDocumentBeforeChange", "before_change", "case_upper", "OFF"), + ("fullDocumentBeforeChange", "before_change", "case_when_available", "WhenAvailable"), + ("fullDocumentBeforeChange", "before_change", "leading_mixed_ws", " \t\n\r off"), + ("fullDocumentBeforeChange", "before_change", "trailing_space", "off "), + ("fullDocumentBeforeChange", "before_change", "trailing_tab", "off\t"), + ("fullDocumentBeforeChange", "before_change", "trailing_cr", "off\r"), + ("fullDocumentBeforeChange", "before_change", "trailing_lf", "whenAvailable\n"), + ("fullDocumentBeforeChange", "before_change", "interior_space", "when available"), + ("fullDocumentBeforeChange", "before_change", "trailing_nul", "off\x00"), + ("fullDocumentBeforeChange", "before_change", "leading_nul", "\x00off"), + ("fullDocumentBeforeChange", "before_change", "interior_nul", "off\x00off"), + # Fullwidth latin small o (U+FF4F) in place of the leading ASCII o. + ("fullDocumentBeforeChange", "before_change", "fullwidth_o", "\uff4fff"), + # No-break space (U+00A0) prefix. + ("fullDocumentBeforeChange", "before_change", "nbsp", "\u00a0off"), + # En space (U+2000) prefix. + ("fullDocumentBeforeChange", "before_change", "en_space", "\u2000off"), + # Em space (U+2003) suffix. + ("fullDocumentBeforeChange", "before_change", "em_space", "off\u2003"), + # Byte order mark (U+FEFF) prefix. + ("fullDocumentBeforeChange", "before_change", "bom", "\ufeffoff"), + # Zero-width space (U+200B) prefix. + ("fullDocumentBeforeChange", "before_change", "zwsp", "\u200boff"), + # Zero-width joiner (U+200D) prefix. + ("fullDocumentBeforeChange", "before_change", "zwj", "\u200doff"), + # Left-to-right mark (U+200E) prefix. + ("fullDocumentBeforeChange", "before_change", "ltr_mark", "\u200eoff"), + # Right-to-left mark (U+200F) prefix. + ("fullDocumentBeforeChange", "before_change", "rtl_mark", "\u200foff"), + ("fullDocumentBeforeChange", "before_change", "dollar", "$"), + ("fullDocumentBeforeChange", "before_change", "dollar_value", "$off"), + ] +] + +# Property [Resume Token Type Rejection]: each resume-token option (resumeAfter, +# startAfter) rejects any non-object, non-null BSON type with a TypeMismatch +# error; there is no coercion of a scalar, string, or array to a resume-token +# object. +CHANGESTREAM_RESUME_TOKEN_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"resume_token_type_{opt_id}_{tid}", + pipeline=[{"$changeStream": {opt: val}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$changeStream should reject a non-object value for {opt}", + ) + for opt, opt_id in [ + ("resumeAfter", "resume_after"), + ("startAfter", "start_after"), + ] + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", []), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Resume Token Structure Rejection]: a resume-token object that fails +# structure validation is rejected with an error reflecting the failure mode; a +# missing or non-string _data, an empty-string _data, a non-hex or odd-length +# _data, a well-formed-hex _data that decodes to a malformed KeyString, and a +# wrong-type _typeBits each produce their own distinct error. +CHANGESTREAM_RESUME_TOKEN_STRUCTURE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"resume_token_structure_{opt_id}_{suffix}", + pipeline=[{"$changeStream": {opt: token}}], + error_code=error_code, + msg=f"$changeStream should reject a malformed {opt} resume token ({suffix})", + ) + for opt, opt_id in [ + ("resumeAfter", "resume_after"), + ("startAfter", "start_after"), + ] + for token, error_code, suffix in [ + ({}, RESUME_TOKEN_MALFORMED_ERROR, "empty_object"), + ({"a": 1}, RESUME_TOKEN_MALFORMED_ERROR, "no_data_key"), + ({"_data": 42}, RESUME_TOKEN_MALFORMED_ERROR, "data_int"), + ({"_data": None}, RESUME_TOKEN_MALFORMED_ERROR, "data_null"), + ({"_data": True}, RESUME_TOKEN_MALFORMED_ERROR, "data_bool"), + ({"_data": Binary(b"\x01")}, RESUME_TOKEN_MALFORMED_ERROR, "data_binary"), + ({"_data": [1]}, RESUME_TOKEN_MALFORMED_ERROR, "data_array"), + ({"_data": {"x": 1}}, RESUME_TOKEN_MALFORMED_ERROR, "data_object"), + # A DBRef encodes as a BSON object, so it passes the type check and is + # then rejected as a token with no string _data field. + (DBRef("c", 1), RESUME_TOKEN_MALFORMED_ERROR, "dbref"), + ({"_data": ""}, RESUME_TOKEN_EMPTY_ERROR, "data_empty"), + ({"_data": "xyz"}, FAILED_TO_PARSE_ERROR, "data_nonhex"), + ({"_data": "ZZ"}, FAILED_TO_PARSE_ERROR, "data_nonhex_letters"), + ({"_data": "abc"}, FAILED_TO_PARSE_ERROR, "data_odd_length"), + ({"_data": "00"}, KEYSTRING_UNKNOWN_TYPE_ERROR, "data_unknown_keystring_type"), + ( + {"_data": "00", "_typeBits": "x"}, + RESUME_TOKEN_TYPEBITS_WRONG_TYPE_ERROR, + "typebits_string", + ), + ] +] + +# Property [Timestamp Option Type Rejection]: startAtOperationTime rejects any +# non-timestamp, non-null BSON type with a TypeMismatch error; there is no +# coercion from a number, string, datetime, array, or object to a timestamp, and +# an array is not unwrapped to a contained timestamp. +CHANGESTREAM_TIMESTAMP_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"timestamp_type_{tid}", + pipeline=[{"$changeStream": {"startAtOperationTime": val}}], + error_code=TYPE_MISMATCH_ERROR, + msg="$changeStream should reject a non-timestamp value for startAtOperationTime", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + # DBRef encodes as a BSON object and is rejected as a non-timestamp object. + ("dbref", DBRef("c", 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("empty_object", {}), + ("object", {"a": 1}), + ("empty_array", []), + ("array_single", [Timestamp(1, 1)]), + ("array_multi", [Timestamp(1, 1), Timestamp(2, 2)]), + ] +] + +CHANGESTREAM_ERROR_TESTS = ( + CHANGESTREAM_SPEC_NON_OBJECT_TESTS + + CHANGESTREAM_SPEC_UNKNOWN_FIELD_TESTS + + CHANGESTREAM_EXPRESSION_NOT_EVALUATED_TESTS + + CHANGESTREAM_BOOLEAN_TYPE_ERROR_TESTS + + CHANGESTREAM_BOOLEAN_NULL_ERROR_TESTS + + CHANGESTREAM_ENUM_TYPE_ERROR_TESTS + + CHANGESTREAM_ENUM_VALUE_ERROR_TESTS + + CHANGESTREAM_RESUME_TOKEN_TYPE_ERROR_TESTS + + CHANGESTREAM_RESUME_TOKEN_STRUCTURE_ERROR_TESTS + + CHANGESTREAM_TIMESTAMP_TYPE_ERROR_TESTS +) + + +@pytest.mark.requires(change_streams=True) +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CHANGESTREAM_ERROR_TESTS)) +def test_changeStream_error_cases(collection, test_case: StageTestCase): + """Test $changeStream rejects malformed specs and reads option values as literal BSON.""" + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, error_code=test_case.error_code, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/utils/changeStream_common.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/utils/changeStream_common.py new file mode 100644 index 000000000..ece437195 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/utils/changeStream_common.py @@ -0,0 +1,60 @@ +"""Shared $changeStream command builders and resume-token sentinels. + +The builders return command documents; callers issue them with +``execute_command`` at the callsite so the database call is explicit in each +test file. +""" + +from __future__ import annotations + +from typing import Any + + +def change_stream_command( + collection, *, pipeline, aggregate=None, **command_options +) -> dict[str, Any]: + """Build the aggregate command document that wraps a $changeStream pipeline. + + Args: + collection: The fixture collection the stream targets + pipeline: The full ``[{"$changeStream": ...}]`` pipeline to run + (required) + aggregate: Overrides the aggregate target; defaults to the collection + name, pass ``1`` for a database-scoped stream + command_options: Extra keyword arguments merged as top-level command + fields + """ + command: dict[str, Any] = { + "aggregate": collection.name if aggregate is None else aggregate, + "pipeline": pipeline, + "cursor": {}, + } + command.update(command_options) + return command + + +def get_more_command(collection, cursor_id, *, name: str | None = None) -> dict[str, Any]: + """Build a getMore command document for an open change-stream cursor. + + Args: + collection: The fixture collection whose name is the default namespace + cursor_id: The id of the open change-stream cursor to advance + name: The getMore ``collection`` field; defaults to the fixture + collection name, set to ``$cmd.aggregate`` for a database- or + cluster-scoped stream + """ + return { + "getMore": cursor_id, + "collection": collection.name if name is None else name, + "maxTimeMS": 500, + } + + +# Placeholder for the produced resume token inside a $changeStream spec. The +# harness substitutes the token captured by ``produce_token`` before sending +# the spec, so each case can be written as a full $changeStream document. +RESUME_TOKEN = object() + +# Placeholder for a source stream's operationTime, used by the mutual-exclusivity +# cases that pair a resume token with startAtOperationTime. +OPERATION_TIME = object() diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 86884ce4c..c3d2ee6f5 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -17,6 +17,7 @@ PATH_NOT_VIABLE_ERROR = 28 CONFLICTING_UPDATE_OPERATORS_ERROR = 40 CURSOR_NOT_FOUND_ERROR = 43 +NO_MATCHING_DOCUMENT_ERROR = 47 NAMESPACE_EXISTS_ERROR = 48 DOLLAR_PREFIXED_FIELD_NAME_ERROR = 52 INVALID_BSON_ID_ERROR = 53 @@ -49,7 +50,10 @@ QUERY_FEATURE_NOT_ALLOWED = 224 MAX_NESTED_SUB_PIPELINE_ERROR = 232 CONVERSION_FAILURE_ERROR = 241 +INVALID_RESUME_TOKEN_ERROR = 260 OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR = 263 +CHANGE_STREAM_FATAL_ERROR = 280 +CHANGE_STREAM_HISTORY_LOST_ERROR = 286 NO_QUERY_EXECUTION_PLANS_ERROR = 291 QUERY_EXCEEDED_MEMORY_NO_DISK_USE_ERROR = 292 API_VERSION_ERROR = 322 @@ -339,6 +343,10 @@ OUT_NOT_LAST_STAGE_ERROR = 40601 NOT_FIRST_STAGE_ERROR = 40602 GEO_NEAR_NOT_FIRST_STAGE_ERROR = 40603 +RESUME_TOKEN_MALFORMED_ERROR = 40647 +RESUME_TOKEN_TYPEBITS_WRONG_TYPE_ERROR = 40648 +RESUME_TOKEN_EMPTY_ERROR = 40649 +MULTIPLE_RESUME_OPTIONS_ERROR = 40674 DATEFROMSTRING_INVALID_FORMAT_TYPE_ERROR = 40684 DATEFROMSTRING_INVALID_FORMAT_ERROR = 40685 TRIM_UNKNOWN_FIELD_ERROR = 50694 @@ -349,8 +357,10 @@ TO_TYPE_ARITY_ERROR = 50723 CURSOR_SESSION_MISMATCH_ERROR = 50738 SUBSTR_NEGATIVE_START_ERROR = 50752 +KEYSTRING_UNKNOWN_TYPE_ERROR = 50811 CLUSTERED_NAN_DUPLICATE_ERROR = 50819 CLUSTERED_INFINITY_DUPLICATE_ERROR = 50826 +RESUME_AFTER_START_AFTER_CONFLICT_ERROR = 50865 PLAN_CACHE_STATS_COLLECTION_NOT_FOUND_ERROR = 50933 LOOKUP_SUB_PIPELINE_NOT_ALLOWED_ERROR = 51047 REGEX_OPTIONS_BEFORE_REGEX_FLAGS_ERROR = 51074 @@ -485,6 +495,7 @@ DATEADD_INT64_MIN_NEGATE_ERROR = 6045000 CLUSTERED_INDEX_CAPPED_CONFLICT_ERROR = 6049200 CLUSTERED_INDEX_MAX_CONFLICT_ERROR = 6049204 +CHANGE_STREAM_SPEC_NOT_OBJECT_ERROR = 6188500 ENCRYPTED_FIELD_EMPTY_PATH_ERROR = 6316402 ENCRYPTED_FIELD_ID_PATH_ERROR = 6316403 ENCRYPTED_FIELD_DUPLICATE_PATH_ERROR = 6338402 From e904e5cd562cf02b21828dcf29cdf99110800d05 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 24 Jun 2026 15:34:39 -0700 Subject: [PATCH 03/51] Add listLocalSessions stage tests (#605) Signed-off-by: Daniel Frankcom --- .../test_stages_position_listLocalSessions.py | 219 ++++++++ .../test_listLocalSessions_argument_errors.py | 500 ++++++++++++++++++ ...est_listLocalSessions_invocation_errors.py | 117 ++++ .../test_listLocalSessions_source_stage.py | 103 ++++ .../test_listLocalSessions_success.py | 294 ++++++++++ documentdb_tests/framework/error_codes.py | 1 + documentdb_tests/framework/property_checks.py | 23 + 7 files changed, 1257 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_listLocalSessions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_argument_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_invocation_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_source_stage.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_success.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_listLocalSessions.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_listLocalSessions.py new file mode 100644 index 000000000..ebe2ef358 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_listLocalSessions.py @@ -0,0 +1,219 @@ +"""Tests for $listLocalSessions pipeline position, sub-pipeline placement, and composition.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from pymongo.collection import Collection +from pymongo.database import Database + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_NAMESPACE_ERROR, + NOT_FIRST_STAGE_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, Exists, Gte, IsType, NotExists + +# Property [Stage Position Errors]: $listLocalSessions must be the first pipeline stage, +# so it is rejected when any stage precedes it. +LISTLOCALSESSIONS_NOT_FIRST_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "after_documents", + command={ + "aggregate": 1, + "pipeline": [{"$documents": [{"a": 1}]}, {"$listLocalSessions": {}}], + "cursor": {}, + }, + error_code=NOT_FIRST_STAGE_ERROR, + msg="$listLocalSessions should be rejected when it follows another stage", + ), +] + +# Property [Sub-pipeline Placement Errors]: $listLocalSessions requires a collection-less +# namespace, so it is rejected as the first stage of a collection sub-pipeline +# which supplies a collection namespace. +LISTLOCALSESSIONS_SUB_PIPELINE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "union_with_coll_subpipeline", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [ + {"$unionWith": {"coll": ctx.collection, "pipeline": [{"$listLocalSessions": {}}]}} + ], + "cursor": {}, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="$listLocalSessions should be rejected as the first stage of a " + "$unionWith collection sub-pipeline", + ), + CommandTestCase( + "facet_subpipeline", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$facet": {"f": [{"$listLocalSessions": {}}]}}], + "cursor": {}, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="$listLocalSessions should be rejected as a $facet sub-pipeline first stage", + ), + CommandTestCase( + "lookup_subpipeline", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [ + { + "$lookup": { + "from": ctx.collection, + "pipeline": [{"$listLocalSessions": {}}], + "as": "sessions", + } + } + ], + "cursor": {}, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="$listLocalSessions should be rejected as a $lookup sub-pipeline first stage", + ), +] + +LISTLOCALSESSIONS_POSITION_TESTS = ( + LISTLOCALSESSIONS_NOT_FIRST_ERROR_TESTS + LISTLOCALSESSIONS_SUB_PIPELINE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test", pytest_params(LISTLOCALSESSIONS_POSITION_TESTS)) +def test_listLocalSessions_position( + database_client: Database, collection: Collection, test: CommandTestCase +): + """Test $listLocalSessions pipeline position and sub-pipeline placement errors.""" + target = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(target) + result = execute_command(target, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@dataclass(frozen=True) +class ListLocalSessionsCompositionTestCase(CommandTestCase): + """Composition test case carrying its session requirement. + + Attributes: + num_sessions: Number of explicit sessions to open before running the + command, so the local session cache holds that many entries. The + command runs inside the first session. + """ + + num_sessions: int = 1 + + +# Property [Downstream Stage Composition]: as a first-stage source, $listLocalSessions +# emits a document stream that downstream stages transform like any other source. Each case +# runs inside one or more explicit sessions so the local session cache is populated, and +# asserts output that a no-op stage could not produce. +LISTLOCALSESSIONS_COMPOSITION_TESTS: list[ListLocalSessionsCompositionTestCase] = [ + ListLocalSessionsCompositionTestCase( + "followed_by_match_none", + command={ + "aggregate": 1, + "pipeline": [{"$listLocalSessions": {}}, {"$match": {"_id": {"$exists": False}}}], + "cursor": {}, + }, + expected=[], + msg="a downstream $match with a filter matching no session should drop every document", + ), + ListLocalSessionsCompositionTestCase( + "followed_by_project", + command={ + "aggregate": 1, + "pipeline": [{"$listLocalSessions": {}}, {"$project": {"lastUse": 1, "_id": 0}}], + "cursor": {}, + }, + expected={"lastUse": IsType("date"), "_id": NotExists()}, + msg="a downstream $project should reshape each session to only the projected field", + ), + ListLocalSessionsCompositionTestCase( + "followed_by_add_fields", + command={ + "aggregate": 1, + "pipeline": [{"$listLocalSessions": {}}, {"$addFields": {"marker": "present"}}], + "cursor": {}, + }, + expected={"_id": Exists(), "lastUse": IsType("date"), "marker": Eq("present")}, + msg="a downstream $addFields should add a computed field while preserving originals", + ), + ListLocalSessionsCompositionTestCase( + "followed_by_count", + num_sessions=3, + command={ + "aggregate": 1, + "pipeline": [{"$listLocalSessions": {"allUsers": True}}, {"$count": "numSessions"}], + "cursor": {}, + }, + expected={"numSessions": Gte(3)}, + msg="a downstream $count should collapse the multi-session stream into one count document", + ), + ListLocalSessionsCompositionTestCase( + "followed_by_group", + num_sessions=3, + command={ + "aggregate": 1, + "pipeline": [ + {"$listLocalSessions": {"allUsers": True}}, + {"$group": {"_id": None, "numSessions": {"$sum": 1}}}, + ], + "cursor": {}, + }, + expected={"_id": Eq(None), "numSessions": Gte(3)}, + msg="a downstream $group should aggregate the multi-session stream into a single group", + ), + ListLocalSessionsCompositionTestCase( + "followed_by_skip_limit", + num_sessions=3, + command={ + "aggregate": 1, + "pipeline": [ + {"$listLocalSessions": {"allUsers": True}}, + {"$skip": 1}, + {"$limit": 1}, + {"$count": "numKept"}, + ], + "cursor": {}, + }, + expected={"numKept": Eq(1)}, + msg="a downstream $skip and $limit should cap the multi-session stream to one document", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test", pytest_params(LISTLOCALSESSIONS_COMPOSITION_TESTS)) +def test_listLocalSessions_composition( + collection: Collection, test: ListLocalSessionsCompositionTestCase +): + """Test downstream stages transform the $listLocalSessions source stream.""" + # Open the requested number of sessions so the local session cache is populated, + # then run the command inside the first one. + client = collection.database.client + sessions = [client.start_session() for _ in range(test.num_sessions)] + try: + for session in sessions: + collection.database.command("ping", session=session) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx), session=sessions[0]) + finally: + for session in sessions: + session.end_session() + assertResult(result, expected=test.build_expected(ctx), msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_argument_errors.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_argument_errors.py new file mode 100644 index 000000000..7ab53fdf5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_argument_errors.py @@ -0,0 +1,500 @@ +"""Tests for $listLocalSessions invalid-argument errors: types, fields, and mutual exclusion.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + UNSUPPORTED_FORMAT_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_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [Null and Missing Behavior (errors)]: a null stage argument is rejected +# as a non-object, and a present-but-null required sub-field is counted as missing. +LISTLOCALSESSIONS_NULL_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "null_stage_argument", + pipeline=[{"$listLocalSessions": None}], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject a null stage argument as a non-object", + ), + StageTestCase( + "user_null", + pipeline=[{"$listLocalSessions": {"users": [{"user": None, "db": "admin"}]}}], + error_code=MISSING_FIELD_ERROR, + msg="$listLocalSessions should count a present-but-null user as missing", + ), + StageTestCase( + "db_null", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": None}]}}], + error_code=MISSING_FIELD_ERROR, + msg="$listLocalSessions should count a present-but-null db as missing", + ), +] + +# Property [Stage Argument Type Strictness (errors)]: a non-object stage argument is rejected. +LISTLOCALSESSIONS_ARGUMENT_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"argument_type_{tid}", + pipeline=[{"$listLocalSessions": val}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$listLocalSessions should reject a {tid} stage argument as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Stage Argument Array Rejection (errors)]: an array stage argument is rejected. +LISTLOCALSESSIONS_ARGUMENT_ARRAY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "argument_empty_array", + pipeline=[{"$listLocalSessions": []}], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject an empty array stage argument without unwrapping", + ), + StageTestCase( + "argument_nonempty_array", + pipeline=[{"$listLocalSessions": [{"user": "nobody", "db": "admin"}]}], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject a non-empty array stage argument without unwrapping", + ), +] + +# Property [users Field Type Strictness (errors)]: a non-array users value is rejected, +# including an expression object. +LISTLOCALSESSIONS_USERS_TYPE_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"users_type_{tid}", + pipeline=[{"$listLocalSessions": {"users": val}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$listLocalSessions should reject a {tid} users value as a non-array", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("object", {"user": "nobody", "db": "admin"}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + StageTestCase( + "users_expression_object", + pipeline=[ + {"$listLocalSessions": {"users": {"$literal": [{"user": "nobody", "db": "admin"}]}}} + ], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject an expression object users value as a " + "non-array, without unwrapping or expression evaluation", + ), +] + +# Property [users Element Type Strictness (errors)]: a non-object users element is +# rejected, including one following a valid element. +LISTLOCALSESSIONS_ELEMENT_TYPE_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"element_type_{tid}", + pipeline=[{"$listLocalSessions": {"users": [val]}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$listLocalSessions should reject a {tid} users element as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + StageTestCase( + "element_invalid_after_valid", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": "admin"}, "x"]}}], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should validate every element and reject a non-object " + "element following a valid element", + ), +] + +# Property [user Sub-field Type Strictness (errors)]: a non-string user sub-field is +# rejected, including an expression object. +LISTLOCALSESSIONS_USER_TYPE_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"user_type_{tid}", + pipeline=[{"$listLocalSessions": {"users": [{"user": val, "db": "admin"}]}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$listLocalSessions should reject a {tid} user sub-field as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", ["nobody"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + StageTestCase( + "user_expression_object", + pipeline=[{"$listLocalSessions": {"users": [{"user": {"$literal": "x"}, "db": "admin"}]}}], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject an expression object user sub-field as a " + "non-string, without unwrapping or expression evaluation", + ), +] + +# Property [db Sub-field Type Strictness (errors)]: a non-string db sub-field is +# rejected, including an expression object. +LISTLOCALSESSIONS_DB_TYPE_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"db_type_{tid}", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": val}]}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$listLocalSessions should reject a {tid} db sub-field as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("array", ["admin"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + StageTestCase( + "db_expression_object", + pipeline=[ + {"$listLocalSessions": {"users": [{"user": "nobody", "db": {"$concat": ["a", "b"]}}]}} + ], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject an expression object db sub-field as a " + "non-string, without unwrapping or expression evaluation", + ), +] + +# Property [allUsers Type Strictness (errors)]: a non-boolean allUsers value is +# rejected, including an expression object. +LISTLOCALSESSIONS_ALLUSERS_TYPE_ERROR_TESTS: list[StageTestCase] = [ + *[ + StageTestCase( + f"allusers_type_{tid}", + pipeline=[{"$listLocalSessions": {"allUsers": val}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$listLocalSessions should reject a {tid} allUsers value as a non-boolean", + ) + for tid, val in [ + ("int64", Int64(1)), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + StageTestCase( + "allusers_expression_object", + pipeline=[{"$listLocalSessions": {"allUsers": {"$literal": True}}}], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject an expression object allUsers value as a " + "non-boolean, without unwrapping or expression evaluation", + ), +] + +# Property [allUsers Coercion (errors)]: allUsers does not coerce numbers or strings +# to boolean. +LISTLOCALSESSIONS_ALLUSERS_COERCION_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"allusers_coercion_{tid}", + pipeline=[{"$listLocalSessions": {"allUsers": val}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$listLocalSessions should reject {tid} allUsers without coercion to boolean", + ) + for tid, val in [ + ("zero", 0), + ("one", 1), + ("one_double", 1.0), + ("two_and_half", 2.5), + ("nan", FLOAT_NAN), + ("infinity", FLOAT_INFINITY), + ("empty_string", ""), + ("string_true", "true"), + ("string_false", "false"), + ] +] + +# Property [allUsers Array Rejection (errors)]: an array allUsers value is rejected. +LISTLOCALSESSIONS_ALLUSERS_ARRAY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "allusers_empty_array", + pipeline=[{"$listLocalSessions": {"allUsers": []}}], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject an empty array allUsers value without unwrapping", + ), + StageTestCase( + "allusers_single_element_array", + pipeline=[{"$listLocalSessions": {"allUsers": [True]}}], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject a single-element array allUsers value " + "without unwrapping", + ), + StageTestCase( + "allusers_multi_element_array", + pipeline=[{"$listLocalSessions": {"allUsers": [True, False]}}], + error_code=TYPE_MISMATCH_ERROR, + msg="$listLocalSessions should reject a multi-element array allUsers value " + "without unwrapping", + ), +] + +# Property [Required Sub-field Errors]: a non-null users element missing the user or db +# sub-field is rejected, including after a skipped null. +LISTLOCALSESSIONS_REQUIRED_SUBFIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "element_missing_user", + pipeline=[{"$listLocalSessions": {"users": [{"db": "admin"}]}}], + error_code=MISSING_FIELD_ERROR, + msg="$listLocalSessions should reject an element missing the required user sub-field", + ), + StageTestCase( + "element_missing_db", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody"}]}}], + error_code=MISSING_FIELD_ERROR, + msg="$listLocalSessions should reject an element missing the required db sub-field", + ), + StageTestCase( + "element_empty", + pipeline=[{"$listLocalSessions": {"users": [{}]}}], + error_code=MISSING_FIELD_ERROR, + msg="$listLocalSessions should reject an empty element missing both required sub-fields", + ), + StageTestCase( + "element_invalid_after_null", + pipeline=[{"$listLocalSessions": {"users": [None, {"user": "nobody"}]}}], + error_code=MISSING_FIELD_ERROR, + msg="$listLocalSessions should still validate a non-null element following a " + "skipped null and reject it for a missing required sub-field", + ), +] + +# Property [Unknown Field Errors (Syntax Validation)]: an unrecognized field name is +# rejected; field names are matched case-sensitively at the top level and inside elements. +LISTLOCALSESSIONS_UNKNOWN_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "unknown_top_level_field", + pipeline=[{"$listLocalSessions": {"foo": 1}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject an unknown top-level field", + ), + StageTestCase( + "unknown_top_level_field_with_valid", + pipeline=[{"$listLocalSessions": {"foo": 1, "allUsers": None}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject an unknown top-level field even when a " + "valid field is also present", + ), + StageTestCase( + "top_level_allusers_lowercase", + pipeline=[{"$listLocalSessions": {"allusers": True}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject a lowercase allUsers as an unknown field", + ), + StageTestCase( + "top_level_allusers_capitalized", + pipeline=[{"$listLocalSessions": {"AllUsers": True}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject a capitalized AllUsers as an unknown field", + ), + StageTestCase( + "top_level_users_capitalized", + pipeline=[{"$listLocalSessions": {"Users": []}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject a capitalized Users as an unknown field", + ), + StageTestCase( + "top_level_users_uppercase", + pipeline=[{"$listLocalSessions": {"USERS": []}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject an uppercase USERS as an unknown field", + ), + StageTestCase( + "element_extra_subfield", + pipeline=[ + {"$listLocalSessions": {"users": [{"user": "nobody", "db": "admin", "extra": 1}]}} + ], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject an unknown sub-field inside a users element", + ), + StageTestCase( + "element_user_capitalized", + pipeline=[{"$listLocalSessions": {"users": [{"User": "nobody", "db": "admin"}]}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject a capitalized User sub-field as an unknown field", + ), + StageTestCase( + "element_user_uppercase", + pipeline=[{"$listLocalSessions": {"users": [{"USER": "nobody", "db": "admin"}]}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject an uppercase USER sub-field as an unknown field", + ), + StageTestCase( + "element_db_capitalized", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "Db": "admin"}]}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject a capitalized Db sub-field as an unknown field", + ), + StageTestCase( + "element_db_uppercase", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "DB": "admin"}]}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$listLocalSessions should reject an uppercase DB sub-field as an unknown field", + ), +] + +# Property [Mutual Exclusion Error]: a users array non-empty after null-skipping +# combined with allUsers: true is rejected. +LISTLOCALSESSIONS_MUTUAL_EXCLUSION_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "users_element_with_all_users_true", + pipeline=[ + { + "$listLocalSessions": { + "users": [{"user": "nobody", "db": "admin"}], + "allUsers": True, + } + } + ], + error_code=UNSUPPORTED_FORMAT_ERROR, + msg="$listLocalSessions should reject a non-empty users array combined with " + "allUsers: true", + ), + StageTestCase( + "users_null_then_element_with_all_users_true", + # The array is non-empty only after the leading null element is skipped, + # confirming the conflict is decided on the post-skip filter, not the raw + # array length. + pipeline=[ + { + "$listLocalSessions": { + "users": [None, {"user": "nobody", "db": "admin"}], + "allUsers": True, + } + } + ], + error_code=UNSUPPORTED_FORMAT_ERROR, + msg="$listLocalSessions should reject a users array that is non-empty after " + "null-element skipping combined with allUsers: true", + ), +] + +LISTLOCALSESSIONS_ERROR_TESTS = ( + LISTLOCALSESSIONS_NULL_ERROR_TESTS + + LISTLOCALSESSIONS_ARGUMENT_TYPE_ERROR_TESTS + + LISTLOCALSESSIONS_ARGUMENT_ARRAY_ERROR_TESTS + + LISTLOCALSESSIONS_USERS_TYPE_ERROR_TESTS + + LISTLOCALSESSIONS_ELEMENT_TYPE_ERROR_TESTS + + LISTLOCALSESSIONS_USER_TYPE_ERROR_TESTS + + LISTLOCALSESSIONS_DB_TYPE_ERROR_TESTS + + LISTLOCALSESSIONS_ALLUSERS_TYPE_ERROR_TESTS + + LISTLOCALSESSIONS_ALLUSERS_COERCION_ERROR_TESTS + + LISTLOCALSESSIONS_ALLUSERS_ARRAY_ERROR_TESTS + + LISTLOCALSESSIONS_REQUIRED_SUBFIELD_ERROR_TESTS + + LISTLOCALSESSIONS_UNKNOWN_FIELD_ERROR_TESTS + + LISTLOCALSESSIONS_MUTUAL_EXCLUSION_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LISTLOCALSESSIONS_ERROR_TESTS)) +def test_listLocalSessions_error(collection: Collection, test_case: StageTestCase): + """Test $listLocalSessions stage error cases.""" + result = execute_command( + collection, + {"aggregate": 1, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertFailureCode(result, test_case.error_code, msg=test_case.msg) # type: ignore[arg-type] diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_invocation_errors.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_invocation_errors.py new file mode 100644 index 000000000..fd470cfe9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_invocation_errors.py @@ -0,0 +1,117 @@ +"""Tests for $listLocalSessions invocation errors: collection scope, transactions, apiStrict.""" + +from __future__ import annotations + +import pytest +from pymongo.collection import Collection +from pymongo.database import Database + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertResult +from documentdb_tests.framework.error_codes import ( + API_STRICT_ERROR, + INVALID_NAMESPACE_ERROR, + NOT_FIRST_STAGE_ERROR, + OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Stage Position Errors]: $listLocalSessions must be the first pipeline stage, +# so it is rejected when it follows another $listLocalSessions. +LISTLOCALSESSIONS_NOT_FIRST_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "after_list_local_sessions", + command={ + "aggregate": 1, + "pipeline": [{"$listLocalSessions": {}}, {"$listLocalSessions": {}}], + "cursor": {}, + }, + error_code=NOT_FIRST_STAGE_ERROR, + msg="$listLocalSessions should be rejected when it follows another $listLocalSessions", + ), +] + +# Property [Collection-Level Invocation Errors]: $listLocalSessions must run against the +# database via aggregate: 1, so collection-level invocation is rejected. +LISTLOCALSESSIONS_COLLECTION_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collection_level", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$listLocalSessions": {}}], + "cursor": {}, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="$listLocalSessions should be rejected when invoked at the collection level", + ), +] + +# Property [apiStrict Invocation Errors]: $listLocalSessions is not in Stable API V1, +# so it is rejected under apiStrict: true for every argument shape. +LISTLOCALSESSIONS_API_STRICT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"api_strict_{arg_id}", + command={ + "aggregate": 1, + "pipeline": [{"$listLocalSessions": arg}], + "cursor": {}, + "apiVersion": "1", + "apiStrict": True, + }, + error_code=API_STRICT_ERROR, + msg=f"$listLocalSessions should be rejected under apiStrict for the {arg_id} " + "argument shape", + ) + for arg_id, arg in [ + ("empty", {}), + ("all_users_true", {"allUsers": True}), + ("users_filter", {"users": [{"user": "nobody", "db": "admin"}]}), + ] +] + +LISTLOCALSESSIONS_INVOCATION_ERROR_TESTS = ( + LISTLOCALSESSIONS_NOT_FIRST_ERROR_TESTS + + LISTLOCALSESSIONS_COLLECTION_LEVEL_ERROR_TESTS + + LISTLOCALSESSIONS_API_STRICT_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test", pytest_params(LISTLOCALSESSIONS_INVOCATION_ERROR_TESTS)) +def test_listLocalSessions_invocation_error( + database_client: Database, collection: Collection, test: CommandTestCase +): + """Test $listLocalSessions stage invocation errors.""" + target = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(target) + result = execute_command(target, test.build_command(ctx)) + assertResult( + result, expected=test.build_expected(ctx), error_code=test.error_code, msg=test.msg + ) + + +# Property [Transaction Invocation Errors]: $listLocalSessions is rejected when run +# inside a transaction. Transactions require a replica set topology. +@pytest.mark.aggregate +@pytest.mark.requires(transactions=True) +def test_listLocalSessions_in_transaction(collection: Collection): + """Test $listLocalSessions is rejected when run inside a transaction.""" + command = { + "aggregate": 1, + "pipeline": [{"$listLocalSessions": {}}], + "cursor": {}, + } + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command(collection, command, session=session) + session.abort_transaction() + assertFailureCode( + result, + OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$listLocalSessions should be rejected when run inside a transaction", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_source_stage.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_source_stage.py new file mode 100644 index 000000000..4f7d4f7fb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_source_stage.py @@ -0,0 +1,103 @@ +"""Tests for $listLocalSessions source-stage behavior: output shape and database independence.""" + +from __future__ import annotations + +import pytest +from pymongo.collection import Collection + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import ( + ByteLen, + Eq, + IsType, +) + + +# Property [Return Type / Output Shape]: each session document is {_id: {id, uid}, lastUse}, +# where id is the session's UUID, uid is a 32-byte digest of the authenticated user, and +# lastUse is a date. +@pytest.mark.aggregate +def test_listLocalSessions_output_shape(collection: Collection): + """Test $listLocalSessions session document shape.""" + # Match the opened session's id so we validate the document we control. + with collection.database.client.start_session() as session: + collection.database.command("ping", session=session) + session_id = session.session_id["id"] + result = execute_command( + collection, + { + "aggregate": 1, + "pipeline": [ + {"$listLocalSessions": {}}, + {"$match": {"_id.id": session_id}}, + ], + "cursor": {}, + }, + session=session, + ) + assertResult( + result, + expected={ + "_id": { + "id": Eq(session_id), + "uid": ByteLen(32), + }, + "lastUse": IsType("date"), + }, + msg="$listLocalSessions returns the opened session shaped as {_id: {id, uid}, lastUse}", + ) + + +# Property [uid Identifies the User]: uid is a digest of the authenticated user, not the +# session, so two distinct sessions of the same user share one uid while their ids differ. +# Grouping both sessions by uid therefore yields a single group holding both session ids. +@pytest.mark.aggregate +def test_listLocalSessions_uid_is_per_user(collection: Collection): + """Test two sessions of one user group under a single shared uid.""" + client = collection.database.client + with client.start_session() as first, client.start_session() as second: + collection.database.command("ping", session=first) + collection.database.command("ping", session=second) + first_id = first.session_id["id"] + second_id = second.session_id["id"] + result = execute_command( + collection, + { + "aggregate": 1, + "pipeline": [ + {"$listLocalSessions": {}}, + {"$match": {"_id.id": {"$in": [first_id, second_id]}}}, + {"$group": {"_id": "$_id.uid", "sessionIds": {"$addToSet": "$_id.id"}}}, + {"$project": {"_id": 0, "numSessions": {"$size": "$sessionIds"}}}, + ], + "cursor": {}, + }, + session=first, + ) + assertResult( + result, + expected=[{"numSessions": 2}], + msg="two sessions of one user should group under a single shared uid", + ) + + +# Property [Database Independence]: $listLocalSessions is instance-local, so it succeeds +# regardless of which database the aggregate command targets. Cover both an admin and a +# non-admin database, since a system stage could plausibly be gated to the admin database; +# the target database is otherwise immaterial because the stage reads instance-local state. +@pytest.mark.aggregate +@pytest.mark.parametrize("database", ["admin", "config"]) +def test_listLocalSessions_database_independence(collection: Collection, database: str): + """Test $listLocalSessions succeeds regardless of the target database.""" + db = collection.database.client[database] + result = execute_command( + db[collection.name], + {"aggregate": 1, "pipeline": [{"$listLocalSessions": {}}], "cursor": {}}, + ) + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg=f"$listLocalSessions should succeed against the {database!r} database", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_success.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_success.py new file mode 100644 index 000000000..0ef6e4f33 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/listLocalSessions/test_listLocalSessions_success.py @@ -0,0 +1,294 @@ +"""Tests for $listLocalSessions stage success behavior across valid argument shapes.""" + +from __future__ import annotations + +import pytest +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Stage Argument Acceptance]: the no-filter shapes ({} and {allUsers: }) +# are accepted. +LISTLOCALSESSIONS_ARGUMENT_ACCEPTANCE_TESTS: list[StageTestCase] = [ + StageTestCase( + "empty_document", + pipeline=[{"$listLocalSessions": {}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should accept an empty document", + ), + StageTestCase( + "all_users_true", + pipeline=[{"$listLocalSessions": {"allUsers": True}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should accept allUsers: true", + ), + StageTestCase( + "all_users_false", + pipeline=[{"$listLocalSessions": {"allUsers": False}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should accept allUsers: false", + ), +] + +# Property [Null and Missing Behavior]: a null or absent users/allUsers field is treated +# as absent, and a null users element is silently skipped. +LISTLOCALSESSIONS_NULL_MISSING_TESTS: list[StageTestCase] = [ + StageTestCase( + "users_null", + pipeline=[{"$listLocalSessions": {"users": None}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should treat a null users field as absent", + ), + StageTestCase( + "all_users_null", + pipeline=[{"$listLocalSessions": {"allUsers": None}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should treat a null allUsers field as absent", + ), + StageTestCase( + "users_array_single_null", + pipeline=[{"$listLocalSessions": {"users": [None]}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should silently skip a single null users element", + ), + StageTestCase( + "users_array_element_then_null", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": "admin"}, None]}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should silently skip a null element following a " + "well-formed element", + ), +] + +# Property [users Array and Element Handling]: an empty users array applies no filter +# and the empty-string principal is valid. +LISTLOCALSESSIONS_USERS_ARRAY_TESTS: list[StageTestCase] = [ + StageTestCase( + "users_empty_array", + pipeline=[{"$listLocalSessions": {"users": []}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should accept an empty users array as no filter", + ), + StageTestCase( + "users_empty_principal", + pipeline=[{"$listLocalSessions": {"users": [{"user": "", "db": ""}]}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should accept the empty-string principal", + ), +] + +# Property [Parameter Interactions]: users and allUsers coexist when the users filter is +# empty (after null-skipping) or absent, and allUsers: false never conflicts. +LISTLOCALSESSIONS_PARAMETER_INTERACTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "users_filter_all_users_false", + pipeline=[ + { + "$listLocalSessions": { + "users": [{"user": "nobody", "db": "admin"}], + "allUsers": False, + } + } + ], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should accept a non-empty users filter with allUsers: false", + ), + StageTestCase( + "empty_users_all_users_true", + pipeline=[{"$listLocalSessions": {"users": [], "allUsers": True}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should accept an empty users array with allUsers: true " + "without conflict", + ), + StageTestCase( + "null_element_users_all_users_true", + pipeline=[{"$listLocalSessions": {"users": [None], "allUsers": True}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should accept a users array that is empty after " + "null-skipping with allUsers: true without conflict", + ), + StageTestCase( + "users_null_all_users_true", + pipeline=[{"$listLocalSessions": {"users": None, "allUsers": True}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should treat a null users field as absent with allUsers: true", + ), + StageTestCase( + "users_null_all_users_null", + pipeline=[{"$listLocalSessions": {"users": None, "allUsers": None}}], + expected={"ok": Eq(1.0)}, + msg="$listLocalSessions should treat null users and null allUsers as absent, " + "equivalent to an empty document", + ), +] + +# Property [users Filter Acceptance]: a well-formed {user, db} element is accepted and +# applied as a filter, so a principal owning no session yields no sessions. +LISTLOCALSESSIONS_USERS_FILTER_TESTS: list[StageTestCase] = [ + StageTestCase( + "users_well_formed_element", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": "admin"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept a well-formed users element and " + "return no sessions for a non-existent principal", + ), +] + +# Property [users Array and Element Handling]: well-formed elements are accepted +# regardless of duplicates, array size, or sub-field key order. +LISTLOCALSESSIONS_USERS_ELEMENT_TESTS: list[StageTestCase] = [ + StageTestCase( + "users_duplicate_entries", + pipeline=[ + { + "$listLocalSessions": { + "users": [ + {"user": "nobody", "db": "admin"}, + {"user": "nobody", "db": "admin"}, + ] + } + } + ], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept duplicate identical elements without " + "de-duplication error", + ), + StageTestCase( + "users_large_array_10000", + pipeline=[ + { + "$listLocalSessions": { + "users": [{"user": f"u{i}", "db": "admin"} for i in range(10_000)] + } + } + ], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept a large users array", + ), + StageTestCase( + "users_key_order_db_user", + pipeline=[{"$listLocalSessions": {"users": [{"db": "admin", "user": "nobody"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should treat sub-field key order as irrelevant", + ), + StageTestCase( + "users_whitespace_principal", + pipeline=[{"$listLocalSessions": {"users": [{"user": " ", "db": " "}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should treat a whitespace principal as a distinct " + "non-matching principal", + ), +] + +# Property [user Sub-field Content]: the user sub-field is matched as an opaque string +# with no interpretation, coercion, or length limit. +LISTLOCALSESSIONS_USER_CONTENT_TESTS: list[StageTestCase] = [ + StageTestCase( + "user_digits_only", + pipeline=[{"$listLocalSessions": {"users": [{"user": "12345", "db": "admin"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should keep a digits-only user as a literal string, " + "not coerce it to a number", + ), + StageTestCase( + "user_dollar_path", + pipeline=[{"$listLocalSessions": {"users": [{"user": "$x", "db": "admin"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should store a dollar-prefixed string as literal " + "text, not a field path or variable reference", + ), + StageTestCase( + "user_unicode", + pipeline=[{"$listLocalSessions": {"users": [{"user": "caf\u00e9\u4e2d", "db": "admin"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept multi-byte Unicode characters in user", + ), + StageTestCase( + "user_embedded_null", + pipeline=[{"$listLocalSessions": {"users": [{"user": "a\x00b", "db": "admin"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept an embedded null character in user", + ), + StageTestCase( + "user_long", + pipeline=[{"$listLocalSessions": {"users": [{"user": "x" * 10_000, "db": "admin"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept a very long user with no " + "operator-specific length limit", + ), +] + +# Property [db Sub-field Content]: the db sub-field is matched as an opaque string with +# no interpretation or coercion, including dot look-alikes that are not the ASCII dot. +LISTLOCALSESSIONS_DB_CONTENT_TESTS: list[StageTestCase] = [ + StageTestCase( + "db_digits_only", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": "12345"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should keep a digits-only db as a literal string, " + "not coerce it to a number", + ), + StageTestCase( + "db_dollar", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": "$"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should store a dollar sign as literal text in db", + ), + StageTestCase( + "db_reserved_ascii", + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": "<>:|?{}[]"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept reserved-looking ASCII characters in db", + ), + StageTestCase( + "db_control_chars", + # Control characters U+0001, U+0007, U+001F. + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": "\x01\x07\x1f"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept embedded control characters in db", + ), + StageTestCase( + "db_unicode_whitespace", + # NBSP U+00A0, en space U+2000, em space U+2003. + pipeline=[ + {"$listLocalSessions": {"users": [{"user": "nobody", "db": "\u00a0\u2000\u2003"}]}} + ], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept Unicode whitespace in db", + ), + StageTestCase( + "db_fullwidth_full_stop", + # Fullwidth full stop U+FF0E, a dot look-alike that is not the ASCII dot. + pipeline=[{"$listLocalSessions": {"users": [{"user": "nobody", "db": "\uff0e"}]}}], + expected={"ok": Eq(1.0), "cursor": {"firstBatch": Eq([])}}, + msg="$listLocalSessions should accept a dot look-alike that is not the ASCII dot in db", + ), +] + +LISTLOCALSESSIONS_SUCCESS_TESTS = ( + LISTLOCALSESSIONS_ARGUMENT_ACCEPTANCE_TESTS + + LISTLOCALSESSIONS_NULL_MISSING_TESTS + + LISTLOCALSESSIONS_USERS_ARRAY_TESTS + + LISTLOCALSESSIONS_PARAMETER_INTERACTION_TESTS + + LISTLOCALSESSIONS_USERS_FILTER_TESTS + + LISTLOCALSESSIONS_USERS_ELEMENT_TESTS + + LISTLOCALSESSIONS_USER_CONTENT_TESTS + + LISTLOCALSESSIONS_DB_CONTENT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LISTLOCALSESSIONS_SUCCESS_TESTS)) +def test_listLocalSessions_success(collection: Collection, test_case: StageTestCase): + """Test $listLocalSessions stage success cases against each case's expected result.""" + result = execute_command( + collection, + {"aggregate": 1, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index c3d2ee6f5..15d5febb0 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -7,6 +7,7 @@ NO_SUCH_KEY_ERROR = 4 GRAPH_CONTAINS_CYCLE_ERROR = 5 FAILED_TO_PARSE_ERROR = 9 +UNSUPPORTED_FORMAT_ERROR = 12 UNAUTHORIZED_ERROR = 13 TYPE_MISMATCH_ERROR = 14 OVERFLOW_ERROR = 15 diff --git a/documentdb_tests/framework/property_checks.py b/documentdb_tests/framework/property_checks.py index 0ffb575cf..0f029cb54 100644 --- a/documentdb_tests/framework/property_checks.py +++ b/documentdb_tests/framework/property_checks.py @@ -328,3 +328,26 @@ def check(self, value: Any, path: str) -> str | None: def __repr__(self) -> str: return f"{type(self).__name__}()" + + +class ByteLen(Check): + """Assert that the field is a bytes-like value with the expected byte length. + + Applies to ``bytes`` and ``Binary`` (which subclasses ``bytes``), for fields + whose contract pins an exact payload size. + """ + + def __init__(self, expected: int) -> None: + self.expected = expected + + def check(self, value: Any, path: str) -> str | None: + if value is _FIELD_ABSENT: + return f"expected '{path}' to be {self.expected} bytes, but field is missing" + if not isinstance(value, (bytes, bytearray)): + return f"expected '{path}' to be bytes, got {type(value).__name__}" + if len(value) != self.expected: + return f"expected '{path}' length {self.expected} bytes, got {len(value)}" + return None + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.expected!r})" From 01405a878c1ab58f78811a904f2e912e37dd8302 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:02:15 -0700 Subject: [PATCH 04/51] Add `setQuerySettings` tests (#610) Signed-off-by: Alina (Xi) Li --- .../__init__.py | 0 .../commands}/__init__.py | 0 .../commands/planCacheClear/__init__.py | 0 .../test_planCacheClear_behavior.py | 0 ...est_planCacheClear_collation_collection.py | 0 .../test_planCacheClear_collection_errors.py | 0 .../test_planCacheClear_core.py | 0 .../test_planCacheClear_dependencies.py | 0 .../test_planCacheClear_field_type.py | 0 .../test_planCacheClear_query_comment_type.py | 0 ...est_planCacheClear_sort_projection_type.py | 0 .../test_smoke_planCacheClear.py | 0 .../test_smoke_planCacheClearFilters.py | 0 .../test_smoke_planCacheListFilters.py | 0 .../commands/planCacheSetFilter/__init__.py | 0 .../test_planCacheSetFilter_core.py | 0 .../test_planCacheSetFilter_cross_command.py | 0 .../test_planCacheSetFilter_effectiveness.py | 0 .../test_planCacheSetFilter_errors.py | 0 .../test_planCacheSetFilter_partial_sparse.py | 0 .../test_smoke_planCacheSetFilter.py | 0 .../test_smoke_removeQuerySettings.py | 0 .../commands/setQuerySettings/__init__.py | 0 .../test_setQuerySettings_behavior.py | 375 +++++++ .../test_setQuerySettings_query_shapes.py | 602 +++++++++++ .../test_setQuerySettings_reject.py | 190 ++++ .../test_setQuerySettings_reject_errors.py | 135 +++ .../test_setQuerySettings_settings.py | 785 ++++++++++++++ .../test_setQuerySettings_type_errors.py | 309 ++++++ ...test_setQuerySettings_validation_errors.py | 427 ++++++++ .../test_setQuerySettings_verification.py | 964 ++++++++++++++++++ .../test_smoke_setQuerySettings.py | 0 .../setQuerySettings/utils/__init__.py | 0 .../utils/setQuerySettings_common.py | 15 + .../core/query_planning/utils/__init__.py | 0 .../utils/settings_test_case.py | 53 + .../tests/core/utils/command_test_case.py | 6 +- documentdb_tests/framework/error_codes.py | 8 + 38 files changed, 3868 insertions(+), 1 deletion(-) rename documentdb_tests/compatibility/tests/core/{query-planning/commands/planCacheClear => query_planning}/__init__.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning/commands/planCacheSetFilter => query_planning/commands}/__init__.py (100%) create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/__init__.py rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClear/test_planCacheClear_behavior.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClear/test_planCacheClear_collation_collection.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClear/test_planCacheClear_collection_errors.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClear/test_planCacheClear_core.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClear/test_planCacheClear_dependencies.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClear/test_planCacheClear_field_type.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClear/test_planCacheClear_query_comment_type.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClear/test_planCacheClear_sort_projection_type.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClear/test_smoke_planCacheClear.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheClearFilters/test_smoke_planCacheClearFilters.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheListFilters/test_smoke_planCacheListFilters.py (100%) create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/__init__.py rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheSetFilter/test_planCacheSetFilter_core.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheSetFilter/test_planCacheSetFilter_cross_command.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheSetFilter/test_planCacheSetFilter_effectiveness.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheSetFilter/test_planCacheSetFilter_errors.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheSetFilter/test_planCacheSetFilter_partial_sparse.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/planCacheSetFilter/test_smoke_planCacheSetFilter.py (100%) rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/removeQuerySettings/test_smoke_removeQuerySettings.py (100%) create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_query_shapes.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_settings.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_validation_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_verification.py rename documentdb_tests/compatibility/tests/core/{query-planning => query_planning}/commands/setQuerySettings/test_smoke_setQuerySettings.py (100%) create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/setQuerySettings_common.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/utils/settings_test_case.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/__init__.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/__init__.py rename to documentdb_tests/compatibility/tests/core/query_planning/__init__.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/__init__.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/__init__.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/__init__.py diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_behavior.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_behavior.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_behavior.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_behavior.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_collation_collection.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_collation_collection.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_collation_collection.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_collation_collection.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_collection_errors.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_collection_errors.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_collection_errors.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_collection_errors.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_core.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_core.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_core.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_core.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_dependencies.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_dependencies.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_dependencies.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_dependencies.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_field_type.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_field_type.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_field_type.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_field_type.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_query_comment_type.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_query_comment_type.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_query_comment_type.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_query_comment_type.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_sort_projection_type.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_sort_projection_type.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_sort_projection_type.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_sort_projection_type.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_smoke_planCacheClear.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_smoke_planCacheClear.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_smoke_planCacheClear.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_smoke_planCacheClear.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_smoke_planCacheClearFilters.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClearFilters/test_smoke_planCacheClearFilters.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_smoke_planCacheClearFilters.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClearFilters/test_smoke_planCacheClearFilters.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_smoke_planCacheListFilters.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheListFilters/test_smoke_planCacheListFilters.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_smoke_planCacheListFilters.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheListFilters/test_smoke_planCacheListFilters.py diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_core.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_core.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_core.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_core.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_cross_command.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_cross_command.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_cross_command.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_cross_command.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_effectiveness.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_effectiveness.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_effectiveness.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_effectiveness.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_errors.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_errors.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_errors.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_errors.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_partial_sparse.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_partial_sparse.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_planCacheSetFilter_partial_sparse.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_planCacheSetFilter_partial_sparse.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_smoke_planCacheSetFilter.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_smoke_planCacheSetFilter.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_smoke_planCacheSetFilter.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_smoke_planCacheSetFilter.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/removeQuerySettings/test_smoke_removeQuerySettings.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_smoke_removeQuerySettings.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/removeQuerySettings/test_smoke_removeQuerySettings.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_smoke_removeQuerySettings.py diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_behavior.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_behavior.py new file mode 100644 index 000000000..e714973c3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_behavior.py @@ -0,0 +1,375 @@ +"""Tests for setQuerySettings command behavioral verification. + +Validates that query settings are retrievable via $querySettings aggregation +stage, removable via removeQuerySettings, and that the response structure +includes expected fields like queryShapeHash and representativeQuery. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setQuerySettings_common import get_query_settings + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Response Structure]: setQuerySettings response includes hash, query, and settings. +SET_QUERY_SETTINGS_RESPONSE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "response_contains_hash", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b1": 1}, + "$db": ctx.database, + } + } + ], + msg="response should contain queryShapeHash", + ), + SettingsTestCase( + "response_contains_representative_query", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b2": 1}, + "$db": ctx.database, + } + } + ], + msg="response should contain representativeQuery", + ), + SettingsTestCase( + "response_settings_echo", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected=lambda ctx: { + "ok": 1.0, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b3": 1}, + "$db": ctx.database, + } + } + ], + msg="response should echo applied settings", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_RESPONSE_TESTS)) +def test_setQuerySettings_response(collection, test): + """Test setQuerySettings response structure.""" + ctx = CommandContext.from_collection(collection) + try: + result = execute_admin_command(collection, test.build_command(ctx)) + expected = test.build_expected(ctx) + # Also verify the dynamic fields are present + if test.id == "response_contains_hash": + expected["queryShapeHash"] = result.get("queryShapeHash") + elif test.id == "response_contains_representative_query": + expected["representativeQuery"] = result.get("representativeQuery") + assertSuccessPartial(result, expected, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [removeQuerySettings]: settings can be removed by query or hash. +SET_QUERY_SETTINGS_REMOVE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "removeQuerySettings_by_query", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b5": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b5": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b5": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings by query should succeed", + ), + SettingsTestCase( + "removeQuerySettings_by_hash", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b6": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: {"removeQuerySettings": ctx.setup_results[0]["queryShapeHash"]}, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b6": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings by hash should succeed", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_REMOVE_TESTS)) +def test_setQuerySettings_remove(collection, test): + """Test removeQuerySettings removes settings.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [$querySettings Retrieval]: settings are visible via $querySettings aggregation stage. +SET_QUERY_SETTINGS_QS_STAGE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "querySettings_stage_retrieval", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b4": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + expected=lambda ctx: {"queryShapeHash": ctx.setup_results[0]["queryShapeHash"]}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b4": 1}, + "$db": ctx.database, + } + } + ], + msg="$querySettings should return the created setting", + ), + SettingsTestCase( + "querySettings_stage_shows_settings", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b9": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + expected=lambda ctx: { + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b9": 1}, + "$db": ctx.database, + } + } + ], + msg="$querySettings should include indexHints in settings", + ), + SettingsTestCase( + "querySettings_stage_shows_representative_query", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b10": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + # expected is built dynamically in the runner (self-referential) + expected=None, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b10": 1}, + "$db": ctx.database, + } + } + ], + msg="$querySettings should include representativeQuery", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_QS_STAGE_TESTS)) +def test_setQuerySettings_qs_stage(collection, test): + """Test that settings are visible via $querySettings aggregation stage.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + expected_hash = ctx.setup_results[0]["queryShapeHash"] + settings = get_query_settings(collection) + matching = [s for s in settings if s.get("queryShapeHash") == expected_hash] + entry = matching[0] if matching else {} + expected = test.build_expected(ctx) + if expected is None: + # Self-referential: verify the field exists + expected = {"representativeQuery": entry.get("representativeQuery")} + assertSuccessPartial(entry, expected, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_query_shapes.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_query_shapes.py new file mode 100644 index 000000000..87d25560e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_query_shapes.py @@ -0,0 +1,602 @@ +"""Tests for setQuerySettings command query shape acceptance. + +Validates that the setQuerySettings command accepts valid query shapes for +find, distinct, and aggregate commands, including various shape variations, +field combinations, and $db field variations. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Command Shape Acceptance]: accepts find, distinct, and aggregate shapes. +# Property [Find Shape Variations]: setQuerySettings accepts find shapes with various field combos. +# Property [Distinct Shape Variations]: setQuerySettings accepts distinct shapes with query combos. +# Property [Aggregate Shape Variations]: setQuerySettings accepts aggregate pipeline shapes. +# Property [$db Field Variations]: setQuerySettings accepts non-existent and special-char db names. +SET_QUERY_SETTINGS_QUERY_SHAPE_TESTS: list[SettingsTestCase] = [ + # -- Command shape acceptance -- + SettingsTestCase( + "find_shape", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "sort": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "sort": {"x": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept valid find shape", + ), + SettingsTestCase( + "distinct_shape", + command=lambda ctx: { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"x": {"$gt": 0}}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"x": {"$gt": 0}}, + "$db": ctx.database, + } + } + ], + msg="should accept valid distinct shape", + ), + SettingsTestCase( + "aggregate_shape", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + } + } + ], + msg="should accept valid aggregate shape", + ), + # -- Find shape variations -- + SettingsTestCase( + "find_filter_only", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with filter only", + ), + SettingsTestCase( + "find_filter_sort", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b": 1}, + "sort": {"b": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b": 1}, + "sort": {"b": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with filter+sort", + ), + SettingsTestCase( + "find_filter_projection", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"c": 1}, + "projection": {"c": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"c": 1}, + "projection": {"c": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with filter+projection", + ), + SettingsTestCase( + "find_filter_sort_projection", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"d": 1}, + "sort": {"d": 1}, + "projection": {"d": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"d": 1}, + "sort": {"d": 1}, + "projection": {"d": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with all fields", + ), + SettingsTestCase( + "find_with_collation", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"e": "abc"}, + "collation": {"locale": "en", "strength": 2}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"e": "abc"}, + "collation": {"locale": "en", "strength": 2}, + "$db": ctx.database, + } + } + ], + msg="should accept find with collation", + ), + SettingsTestCase( + "find_with_let", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"$expr": {"$eq": ["$f", "$$target"]}}, + "let": {"target": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"$expr": {"$eq": ["$f", "$$target"]}}, + "let": {"target": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with let", + ), + SettingsTestCase( + "find_with_limit", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"g": 1}, + "limit": 10, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"g": 1}, + "limit": 10, + "$db": ctx.database, + } + } + ], + msg="should accept find with limit", + ), + # -- Distinct shape variations -- + SettingsTestCase( + "distinct_key_only", + command=lambda ctx: { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "j", + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "j", + "$db": ctx.database, + } + } + ], + msg="should accept distinct key only", + ), + SettingsTestCase( + "distinct_complex_query", + command=lambda ctx: { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "k", + "query": {"$and": [{"k": {"$gt": 0}}, {"k": {"$lt": 100}}]}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "k", + "query": {"$and": [{"k": {"$gt": 0}}, {"k": {"$lt": 100}}]}, + "$db": ctx.database, + } + } + ], + msg="should accept distinct complex query", + ), + # -- Aggregate shape variations -- + SettingsTestCase( + "aggregate_match_only", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"l": 1}}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"l": 1}}], + "$db": ctx.database, + } + } + ], + msg="should accept aggregate $match only", + ), + SettingsTestCase( + "aggregate_match_group", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [ + {"$match": {"m": 1}}, + {"$group": {"_id": "$m", "count": {"$sum": 1}}}, + ], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [ + {"$match": {"m": 1}}, + {"$group": {"_id": "$m", "count": {"$sum": 1}}}, + ], + "$db": ctx.database, + } + } + ], + msg="should accept aggregate $match+$group", + ), + SettingsTestCase( + "aggregate_match_sort_limit", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"n": 1}}, {"$sort": {"n": 1}}, {"$limit": 5}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"n": 1}}, {"$sort": {"n": 1}}, {"$limit": 5}], + "$db": ctx.database, + } + } + ], + msg="should accept aggregate $match+$sort+$limit", + ), + SettingsTestCase( + "aggregate_empty_pipeline", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [], + "$db": ctx.database, + } + } + ], + msg="should accept aggregate with empty pipeline", + ), + # -- $db field variations -- + SettingsTestCase( + "db_nonexistent", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"o": 1}, + "$db": "nonexistent_db_for_query_settings_test", + }, + "settings": { + "indexHints": [ + { + "ns": { + "db": "nonexistent_db_for_query_settings_test", + "coll": ctx.collection, + }, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"o": 1}, + "$db": "nonexistent_db_for_query_settings_test", + } + } + ], + msg="should accept non-existent $db", + ), + SettingsTestCase( + "db_special_characters", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"p": 1}, + "$db": "test-special-db", + }, + "settings": { + "indexHints": [ + { + "ns": {"db": "test-special-db", "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"p": 1}, + "$db": "test-special-db", + } + } + ], + msg="should accept $db with special chars", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_QUERY_SHAPE_TESTS)) +def test_setQuerySettings_query_shapes(collection, test): + """Test setQuerySettings accepts valid query shapes.""" + ctx = CommandContext.from_collection(collection) + try: + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject.py new file mode 100644 index 000000000..ec55cd0db --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject.py @@ -0,0 +1,190 @@ +"""Tests for setQuerySettings reject field success behavior. + +Validates that rejection does not affect unrelated query shapes, +and that reject can be reversed via update or removal. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Reject Scope]: reject: true does not affect unrelated query shapes. +# Property [Reject Reversal via Update]: updating reject to false re-enables the query. +# Property [Reject Reversal via Remove]: removing the query setting re-enables the query. +# Property [Reject False Succeeds]: reject: false with indexHints allows the query. +SET_QUERY_SETTINGS_REJECT_SUCCESS_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "reject_does_not_affect_different_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_s1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"rej_s2": 1}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_s1": 1}, + "$db": ctx.database, + } + } + ], + msg="different query shape should not be rejected", + ), + SettingsTestCase( + "reject_reversed_by_update", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_u1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + }, + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_u1": 1}, + "$db": ctx.database, + }, + "settings": { + "reject": False, + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"rej_u1": 1}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_u1": 1}, + "$db": ctx.database, + } + } + ], + msg="query should succeed after reject updated to false", + ), + SettingsTestCase( + "reject_reversed_by_remove", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_r1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_r1": 1}, + "$db": ctx.database, + } + }, + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"rej_r1": 1}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_r1": 1}, + "$db": ctx.database, + } + } + ], + msg="query should succeed after removeQuerySettings", + ), + SettingsTestCase( + "reject_false_allows_query", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_f1": 1}, + "$db": ctx.database, + }, + "settings": { + "reject": False, + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"rej_f1": 1}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_f1": 1}, + "$db": ctx.database, + } + } + ], + msg="query with reject: false should succeed", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_REJECT_SUCCESS_TESTS)) +def test_setQuerySettings_reject_success(collection, test): + """Test that reject scope and reversal work correctly.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject_errors.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject_errors.py new file mode 100644 index 000000000..1f1beb9ee --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject_errors.py @@ -0,0 +1,135 @@ +"""Tests for setQuerySettings reject field error behavior. + +Validates that reject: true blocks matching queries for find, distinct, and +aggregate commands at execution time. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import QUERYSETTINGS_QUERY_REJECTED_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Reject Blocks Find]: reject: true blocks matching find queries. +# Property [Reject Blocks Distinct]: reject: true blocks matching distinct queries. +# Property [Reject Blocks Aggregate]: reject: true blocks matching aggregate queries. +SET_QUERY_SETTINGS_REJECT_ERROR_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "reject_blocks_find", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b8": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"b8": 1}, + }, + error_code=QUERYSETTINGS_QUERY_REJECTED_ERROR, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b8": 1}, + "$db": ctx.database, + } + } + ], + msg="query matching reject: true setting should be rejected", + ), + SettingsTestCase( + "reject_blocks_distinct", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"rej_d1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + "query": {"rej_d1": 1}, + }, + error_code=QUERYSETTINGS_QUERY_REJECTED_ERROR, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"rej_d1": 1}, + "$db": ctx.database, + } + } + ], + msg="distinct query matching reject: true should be rejected", + ), + SettingsTestCase( + "reject_blocks_aggregate", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"rej_a1": 1}}], + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"rej_a1": 1}}], + "cursor": {}, + }, + error_code=QUERYSETTINGS_QUERY_REJECTED_ERROR, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"rej_a1": 1}}], + "$db": ctx.database, + } + } + ], + msg="aggregate query matching reject: true should be rejected", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_REJECT_ERROR_TESTS)) +def test_setQuerySettings_reject_errors(collection, test): + """Test that reject: true blocks matching queries.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_settings.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_settings.py new file mode 100644 index 000000000..80265cbb1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_settings.py @@ -0,0 +1,785 @@ +"""Tests for setQuerySettings command settings configurations. + +Validates that the setQuerySettings command accepts valid settings +combinations including indexHints, reject, queryFramework, and comment +fields, as well as allowedIndexes variations and update behavior. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [indexHints Acceptance]: setQuerySettings accepts valid indexHints configurations. +# Property [reject Acceptance]: setQuerySettings accepts reject: true alone or with indexHints. +# Property [queryFramework Acceptance]: setQuerySettings accepts classic and sbe frameworks. +# Property [comment Acceptance]: setQuerySettings accepts comment as any BSON type. +# Property [Combined Settings]: setQuerySettings accepts all settings fields together. +# Property [$natural Hint]: setQuerySettings accepts $natural in allowedIndexes. +# Property [Multiple indexHints]: setQuerySettings accepts multiple indexHints documents. +# Property [Non-Existent Index]: setQuerySettings accepts non-existent index names. +# Property [Text Index Spec]: setQuerySettings accepts text index key pattern in allowedIndexes. +# Property [2dsphere Index Spec]: setQuerySettings accepts 2dsphere index key pattern. +# Property [2d Index Spec]: setQuerySettings accepts 2d index key pattern. +# Property [Hashed Index Spec]: setQuerySettings accepts hashed index key pattern. +SET_QUERY_SETTINGS_SETTINGS_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "indexHints_single_index", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a1": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept indexHints with single index", + ), + SettingsTestCase( + "indexHints_multiple_indexes", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_", {"a2": 1}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a2": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept multiple indexes", + ), + SettingsTestCase( + "indexHints_key_pattern", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"a3": 1}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a3": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept indexHints with key pattern", + ), + SettingsTestCase( + "reject_true", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a5": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a5": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with reject: true", + ), + SettingsTestCase( + "reject_with_indexHints", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a6": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "reject": True, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a6": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept reject with indexHints", + ), + SettingsTestCase( + "queryFramework_classic", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a7": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "classic", + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a7": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept queryFramework: classic", + ), + SettingsTestCase( + "queryFramework_sbe", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a8": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "sbe", + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a8": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept queryFramework: sbe", + ), + SettingsTestCase( + "with_comment_string", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a9": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": "test comment for setQuerySettings", + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a9": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment string", + ), + SettingsTestCase( + "all_settings_combined", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a12": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "classic", + "reject": True, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a12": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept all settings combined", + ), + SettingsTestCase( + "indexHints_natural", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a13": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["$natural"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a13": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept $natural in allowedIndexes", + ), + SettingsTestCase( + "indexHints_multiple_ns_documents", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a14": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + }, + { + "ns": {"db": ctx.database, "coll": "other_collection"}, + "allowedIndexes": ["_id_"], + }, + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a14": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept multiple indexHints documents", + ), + SettingsTestCase( + "indexHints_nonexistent_index", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a15": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["nonexistent_index"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a15": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept non-existent index name", + ), + SettingsTestCase( + "comment_object", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a16": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": {"body": {"msg": "Updated"}}, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a16": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as object", + ), + SettingsTestCase( + "comment_int", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a17": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": 42, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a17": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as int", + ), + SettingsTestCase( + "comment_bool", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a18": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": True, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a18": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as bool", + ), + SettingsTestCase( + "comment_array", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a19": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": ["tag1", "tag2"], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a19": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as array", + ), + SettingsTestCase( + "comment_null", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a20": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": None, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a20": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as null", + ), + SettingsTestCase( + "indexHints_text_index_spec", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a21": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"a21": "text"}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a21": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept text index key pattern in allowedIndexes", + ), + SettingsTestCase( + "indexHints_2dsphere_index_spec", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a22": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"geo": "2dsphere"}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a22": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept 2dsphere index key pattern in allowedIndexes", + ), + SettingsTestCase( + "indexHints_2d_index_spec", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a23": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"loc": "2d"}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a23": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept 2d index key pattern in allowedIndexes", + ), + SettingsTestCase( + "indexHints_hashed_index_spec", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a24": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"a24": "hashed"}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a24": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept hashed index key pattern in allowedIndexes", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_SETTINGS_TESTS)) +def test_setQuerySettings_settings(collection, test): + """Test setQuerySettings accepts valid settings configurations.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Update Behavior]: setQuerySettings can update existing settings by query or hash. +SET_QUERY_SETTINGS_UPDATE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "update_existing_settings", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a10": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a10": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_", {"a10": 1}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a10": 1}, + "$db": ctx.database, + } + } + ], + msg="update setQuerySettings should succeed", + ), + SettingsTestCase( + "update_via_hash", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a11": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": ctx.setup_results[0]["queryShapeHash"], + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_", {"a11": 1}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a11": 1}, + "$db": ctx.database, + } + } + ], + msg="update via hash should succeed", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_UPDATE_TESTS)) +def test_setQuerySettings_update(collection, test): + """Test setQuerySettings can update existing settings by query or hash.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_type_errors.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_type_errors.py new file mode 100644 index 000000000..84a83e69d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_type_errors.py @@ -0,0 +1,309 @@ +"""Tests for setQuerySettings command BSON type rejection. + +Validates that the setQuerySettings command rejects invalid BSON types for +the primary argument field, the queryFramework sub-field, the reject sub-field, +and the indexHints namespace and allowedIndexes sub-fields. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Primary Argument Type Rejection]: the setQuerySettings field must +# be a document (query shape) or string (hash). All other BSON types are +# rejected with TYPE_MISMATCH_ERROR. +SET_QUERY_SETTINGS_PRIMARY_ARG_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"primary_arg_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": v, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as the primary argument", + ) + for tid, value in [ + ("null", None), + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2, 3]), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(0, 0)), + ("binary", Binary(b"\x00")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [queryFramework Type Rejection]: the queryFramework field must be a +# string. Non-string BSON types are rejected with TYPE_MISMATCH_ERROR. +SET_QUERY_SETTINGS_QUERY_FRAMEWORK_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"query_framework_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": v, + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as queryFramework", + ) + for tid, value in [ + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [1]), + ("object", {"k": "v"}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(0, 0)), + ("binary", Binary(b"\x00")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [reject Type Rejection]: the reject field must be a boolean. +# Non-boolean BSON types are rejected with TYPE_MISMATCH_ERROR. +SET_QUERY_SETTINGS_REJECT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"reject_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "reject": v, + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as reject field", + ) + for tid, value in [ + ("null", None), + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("string", "true"), + ("array", [True]), + ("object", {"k": "v"}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(0, 0)), + ("binary", Binary(b"\x00")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [indexHints.ns.db Type Rejection]: the ns.db field must be a string. +SET_QUERY_SETTINGS_NS_DB_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"ns_db_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": v, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as indexHints.ns.db", + ) + for tid, value in [ + ("int32", 42), + ("bool", True), + ("array", ["test"]), + ("object", {"k": "v"}), + ] +] + +# Property [indexHints.ns.coll Type Rejection]: the ns.coll field must be a string. +SET_QUERY_SETTINGS_NS_COLL_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"ns_coll_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": v}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as indexHints.ns.coll", + ) + for tid, value in [ + ("int32", 42), + ("bool", True), + ] +] + +# Property [indexHints.allowedIndexes Type Rejection]: allowedIndexes must be an array. +SET_QUERY_SETTINGS_ALLOWED_INDEXES_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"allowed_indexes_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": v, + } + ], + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as indexHints.allowedIndexes", + ) + for tid, value in [ + ("string", "_id_"), + ("int32", 42), + ] +] + +# Property [allowedIndexes null]: null allowedIndexes treated as missing required field. +SET_QUERY_SETTINGS_ALLOWED_INDEXES_EDGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "allowed_indexes_null_missing", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": None, + } + ], + }, + }, + error_code=MISSING_FIELD_ERROR, + msg="setQuerySettings should reject null allowedIndexes as missing field", + ), + CommandTestCase( + "allowed_indexes_non_string_element", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [42], + } + ], + }, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="setQuerySettings should reject non-string elements in allowedIndexes", + ), +] + +SET_QUERY_SETTINGS_TYPE_ERROR_TESTS: list[CommandTestCase] = ( + SET_QUERY_SETTINGS_PRIMARY_ARG_TYPE_TESTS + + SET_QUERY_SETTINGS_QUERY_FRAMEWORK_TYPE_TESTS + + SET_QUERY_SETTINGS_REJECT_TYPE_TESTS + + SET_QUERY_SETTINGS_NS_DB_TYPE_TESTS + + SET_QUERY_SETTINGS_NS_COLL_TYPE_TESTS + + SET_QUERY_SETTINGS_ALLOWED_INDEXES_TYPE_TESTS + + SET_QUERY_SETTINGS_ALLOWED_INDEXES_EDGE_TESTS +) + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_TYPE_ERROR_TESTS)) +def test_setQuerySettings_type_errors(collection, test): + """Test setQuerySettings BSON type rejection.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_validation_errors.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_validation_errors.py new file mode 100644 index 000000000..4e6b31ed9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_validation_errors.py @@ -0,0 +1,427 @@ +"""Tests for setQuerySettings command structural and validation errors. + +Validates that the setQuerySettings command rejects malformed query shapes, +invalid hash strings, missing or empty settings, unrecognized fields, invalid +queryFramework values, system collection restrictions, and that reject: true +blocks matching queries at execution time. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_LENGTH_ERROR, + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + QUERYSETTINGS_EMPTY_SETTINGS_ERROR, + QUERYSETTINGS_IDHACK_QUERY_ERROR, + QUERYSETTINGS_INTERNAL_DB_ERROR, + QUERYSETTINGS_NS_COLL_MISSING_ERROR, + QUERYSETTINGS_NS_DB_MISSING_ERROR, + QUERYSETTINGS_REJECT_ONLY_ERROR, + QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Query Shape Validation]: rejects malformed or unknown query shape documents. +# Property [Hash String Validation]: rejects invalid hash string formats. +# Property [indexHints Structure Validation]: rejects indexHints missing required sub-fields. +# Property [Settings Value Validation]: rejects invalid field values in settings document. +# Property [Settings Presence]: rejects missing or empty settings document. +# Property [Unrecognized Fields]: rejects unknown top-level command fields. +# Property [Database Restrictions]: rejects query shapes targeting internal databases. +# Property [indexHints Value Validation]: rejects empty allowedIndexes and IDHACK queries. +# Property [Reject Blocks Query]: a rejected query returns an error when executed. +SET_QUERY_SETTINGS_VALIDATION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_shape_missing_db", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=MISSING_FIELD_ERROR, + msg="setQuerySettings should reject query shape missing $db field", + ), + CommandTestCase( + "query_shape_empty_db", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": "", + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="setQuerySettings should reject query shape with empty $db", + ), + CommandTestCase( + "query_shape_unknown_command", + command=lambda ctx: { + "setQuerySettings": { + "unknownCommand": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="setQuerySettings should reject unknown command type in query shape", + ), + CommandTestCase( + "empty_hash_string", + command=lambda ctx: { + "setQuerySettings": "", + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=INVALID_LENGTH_ERROR, + msg="setQuerySettings should reject empty hash string", + ), + CommandTestCase( + "short_hash_string", + command=lambda ctx: { + "setQuerySettings": "tooshort", + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=BAD_VALUE_ERROR, + msg="setQuerySettings should reject hash string with wrong length", + ), + CommandTestCase( + "nonhex_hash_string", + command=lambda ctx: { + "setQuerySettings": "ZZZZZZZZ34567890ABCDEF1234567890" + "ABCDEF1234567890ABCDEF1234567890", + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=BAD_VALUE_ERROR, + msg="setQuerySettings should reject hash string with non-hex chars", + ), + CommandTestCase( + "indexHints_missing_ns", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=MISSING_FIELD_ERROR, + msg="setQuerySettings should reject indexHints missing ns field", + ), + CommandTestCase( + "indexHints_ns_missing_db", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_NS_DB_MISSING_ERROR, + msg="setQuerySettings should reject indexHints.ns missing db field", + ), + CommandTestCase( + "indexHints_ns_null_db", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": None, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_NS_DB_MISSING_ERROR, + msg="setQuerySettings should reject indexHints.ns with null db", + ), + CommandTestCase( + "indexHints_ns_missing_coll", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_NS_COLL_MISSING_ERROR, + msg="setQuerySettings should reject indexHints.ns missing coll field", + ), + CommandTestCase( + "indexHints_ns_null_coll", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": None}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_NS_COLL_MISSING_ERROR, + msg="setQuerySettings should reject indexHints.ns with null coll", + ), + CommandTestCase( + "invalid_query_framework_value", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "invalidFramework", + }, + }, + error_code=BAD_VALUE_ERROR, + msg="setQuerySettings should reject invalid queryFramework string", + ), + CommandTestCase( + "reject_false_only", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": {"reject": False}, + }, + error_code=QUERYSETTINGS_REJECT_ONLY_ERROR, + msg="setQuerySettings should reject settings with only reject: false", + ), + CommandTestCase( + "missing_settings", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + }, + error_code=MISSING_FIELD_ERROR, + msg="setQuerySettings should reject missing settings field", + ), + CommandTestCase( + "empty_settings", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": {}, + }, + error_code=QUERYSETTINGS_EMPTY_SETTINGS_ERROR, + msg="setQuerySettings should reject empty settings document", + ), + CommandTestCase( + "unrecognized_top_level_field", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setQuerySettings should reject unrecognized top-level field", + ), + CommandTestCase( + "system_collection", + command=lambda ctx: { + "setQuerySettings": { + "find": "system.users", + "filter": {}, + "$db": "admin", + }, + "settings": { + "indexHints": [ + { + "ns": {"db": "admin", "coll": "system.users"}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_INTERNAL_DB_ERROR, + msg="setQuerySettings should reject query shapes on internal databases", + ), + CommandTestCase( + "local_database", + command=lambda ctx: { + "setQuerySettings": { + "find": "oplog.rs", + "filter": {}, + "$db": "local", + }, + "settings": { + "indexHints": [ + { + "ns": {"db": "local", "coll": "oplog.rs"}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_INTERNAL_DB_ERROR, + msg="setQuerySettings should reject query shapes on local database", + ), + CommandTestCase( + "indexHints_empty_allowed_rejected", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a4": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [], + } + ], + }, + }, + error_code=QUERYSETTINGS_REJECT_ONLY_ERROR, + msg="setQuerySettings should reject indexHints with empty allowedIndexes", + ), + CommandTestCase( + "idhack_query_rejected", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"_id": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_IDHACK_QUERY_ERROR, + msg="setQuerySettings should reject IDHACK-eligible queries", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_VALIDATION_ERROR_TESTS)) +def test_setQuerySettings_validation_errors(collection, test): + """Test setQuerySettings structural and validation error rejection.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_verification.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_verification.py new file mode 100644 index 000000000..fe671ed68 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_verification.py @@ -0,0 +1,964 @@ +"""Tests for setQuerySettings observable effects and verification. + +Validates query shape hash properties, $querySettings stage output for +distinct and aggregate shapes, showDebugQueryShape, multiple settings +management, comment visibility, settings replacement semantics, and +indexHints namespace mismatch acceptance. +""" + +from __future__ import annotations + +import re + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setQuerySettings_common import get_query_settings + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Hash Format]: queryShapeHash is a 64-character hexadecimal string. +# Property [Hash Consistency]: same query shape produces the same hash. +# Property [Hash Uniqueness]: different query shapes produce different hashes. +# Property [Shape Matching]: filter values do not affect shape identity. +# Property [Sort Direction Matters]: different sort directions produce different hashes. +# Property [$querySettings Distinct]: $querySettings returns correct data for distinct. +# Property [$querySettings Aggregate]: $querySettings returns correct data for aggregate. +# Property [showDebugQueryShape True]: debugQueryShape present when requested. +# Property [showDebugQueryShape False]: debugQueryShape absent when not requested. +# Property [Multiple Settings Visible]: all query settings appear in $querySettings. +# Property [Multiple Settings Remove]: removing one leaves others intact. +# Property [Comment Visibility]: settings.comment appears in $querySettings output. +# Property [Comment Update]: updating settings.comment replaces the old value. +# Property [Settings Replacement]: updating settings preserves unmodified fields. +# Property [No Duplicate On Update]: updating same shape does not duplicate entries. +# Property [ns Mismatch]: indexHints ns.coll can differ from query shape collection. + + +# --------------------------------------------------------------------------- +# Group 1: ns.coll mismatch acceptance test +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_NS_MISMATCH_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "ns_coll_mismatch_accepted", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"mis1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": { + "db": ctx.database, + "coll": "completely_different_collection", + }, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"mis1": 1}, + "$db": ctx.database, + } + } + ], + msg="ns.coll mismatch should be accepted", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_NS_MISMATCH_TESTS)) +def test_setQuerySettings_ns_coll_mismatch_accepted(collection, test): + """Test that indexHints ns.coll can differ from query shape collection.""" + ctx = CommandContext.from_collection(collection) + try: + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 2: Hash property tests +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_HASH_SAME_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "same_shape_produces_same_hash", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"h2": 1}, + "$db": ctx.database, + } + } + ], + msg="same query shape should produce identical hashes", + ), + SettingsTestCase( + "filter_values_do_not_affect_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + } + }, + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 999}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 999}, + "$db": ctx.database, + } + } + ], + msg="filter values should not affect query shape hash", + ), +] + +SET_QUERY_SETTINGS_HASH_DIFFERENT_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "different_shapes_different_hashes", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h3a": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h3b": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"h3a": 1}, + "$db": ctx.database, + } + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"h3b": 1}, + "$db": ctx.database, + } + }, + ], + msg="different query shapes should produce different hashes", + ), + SettingsTestCase( + "sort_direction_affects_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sd": 1}, + "sort": {"a": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sd": 1}, + "sort": {"a": -1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sd": 1}, + "sort": {"a": 1}, + "$db": ctx.database, + } + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sd": 1}, + "sort": {"a": -1}, + "$db": ctx.database, + } + }, + ], + msg="sort direction should produce different query shape hashes", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_HASH_SAME_TESTS)) +def test_setQuerySettings_hash_same(collection, test): + """Test that equivalent query shapes produce the same hash.""" + ctx = CommandContext.from_collection(collection) + try: + setup_hash = None + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + if "queryShapeHash" in r: + setup_hash = r["queryShapeHash"] + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial( + result, + {"queryShapeHash": setup_hash}, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_HASH_DIFFERENT_TESTS)) +def test_setQuerySettings_hash_different(collection, test): + """Test that distinct query shapes produce different hashes.""" + ctx = CommandContext.from_collection(collection) + try: + setup_hash = None + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + if "queryShapeHash" in r: + setup_hash = r["queryShapeHash"] + result = execute_admin_command(collection, test.build_command(ctx)) + hashes_differ = result["queryShapeHash"] != setup_hash + assertSuccessPartial( + {"differ": hashes_differ}, + {"differ": True}, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 3: Hash format test +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_HASH_FORMAT_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "hash_is_64_char_hex", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"h1": 1}, + "$db": ctx.database, + } + } + ], + msg="queryShapeHash should be 64-char hex", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_HASH_FORMAT_TESTS)) +def test_setQuerySettings_hash_format(collection, test): + """Test that queryShapeHash matches expected format.""" + ctx = CommandContext.from_collection(collection) + try: + result = execute_admin_command(collection, test.build_command(ctx)) + h = result.get("queryShapeHash", "") + is_valid = bool(re.fullmatch(r"[0-9A-Fa-f]{64}", h)) + assertSuccessPartial( + {"valid": is_valid}, + {"valid": True}, + msg=f"{test.msg}, got: {h!r}", + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 4: $querySettings inspection tests +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_QS_STAGE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "querySettings_returns_distinct_shape", + command=lambda ctx: { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"qs_d1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected=lambda ctx: {"distinct": ctx.collection}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"qs_d1": 1}, + "$db": ctx.database, + } + } + ], + msg="representativeQuery should be a distinct shape", + ), + SettingsTestCase( + "querySettings_returns_aggregate_shape", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"qs_a1": 1}}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected=lambda ctx: {"aggregate": ctx.collection}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"qs_a1": 1}}], + "$db": ctx.database, + } + } + ], + msg="representativeQuery should be an aggregate shape", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_QS_STAGE_TESTS)) +def test_setQuerySettings_qs_stage(collection, test): + """Test $querySettings returns correct representativeQuery.""" + ctx = CommandContext.from_collection(collection) + try: + r = execute_admin_command(collection, test.build_command(ctx)) + settings = get_query_settings(collection) + matching = [s for s in settings if s.get("queryShapeHash") == r["queryShapeHash"]] + entry = matching[0] if matching else {} + assertSuccessPartial( + entry.get("representativeQuery", {}), + test.build_expected(ctx), + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 5: showDebugQueryShape tests +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_DEBUG_SHAPE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "debug_query_shape_present_when_enabled", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"dbg1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"has_debug": True}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"dbg1": 1}, + "$db": ctx.database, + } + } + ], + msg="debugQueryShape should be present with showDebugQueryShape: true", + ), + SettingsTestCase( + "debug_query_shape_absent_when_disabled", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"dbg2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"has_debug": False}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"dbg2": 1}, + "$db": ctx.database, + } + } + ], + msg="debugQueryShape should be absent with showDebugQueryShape: false", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_DEBUG_SHAPE_TESTS)) +def test_setQuerySettings_debug_shape(collection, test): + """Test showDebugQueryShape controls debugQueryShape presence.""" + ctx = CommandContext.from_collection(collection) + expected = test.build_expected(ctx) + show_debug = expected["has_debug"] + try: + execute_admin_command(collection, test.build_command(ctx)) + settings = list( + collection.database.client.admin.aggregate( + [{"$querySettings": {"showDebugQueryShape": show_debug}}] + ) + ) + filter_key = "dbg1" if show_debug else "dbg2" + entry = [ + s + for s in settings + if s.get("representativeQuery", {}).get("filter", {}).get(filter_key) + ] + has_debug = "debugQueryShape" in (entry[0] if entry else {}) + assertSuccessPartial( + {"has_debug": has_debug}, + expected, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 6: Settings field verification via $querySettings +# (comment visibility, comment update, settings replacement) +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_FIELD_VERIFICATION_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "comment_visible_in_querySettings", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"comvis1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": "my-test-comment", + }, + }, + expected={"comment": "my-test-comment"}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"comvis1": 1}, + "$db": ctx.database, + } + } + ], + msg="comment should be visible in $querySettings output", + ), + SettingsTestCase( + "comment_replaced_on_update", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"comup1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": "original", + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"comup1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": "updated", + }, + }, + expected={"comment": "updated"}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"comup1": 1}, + "$db": ctx.database, + } + } + ], + msg="comment should be replaced by the updated value", + ), + SettingsTestCase( + "update_preserves_unmodified_fields", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rep1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "classic", + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rep1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"queryFramework": "classic"}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rep1": 1}, + "$db": ctx.database, + } + } + ], + msg="queryFramework should be preserved after update with only indexHints", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_FIELD_VERIFICATION_TESTS)) +def test_setQuerySettings_field_verification(collection, test): + """Test settings fields are visible and correctly updated in $querySettings.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + r = execute_admin_command(collection, test.build_command(ctx)) + settings = get_query_settings(collection) + matching = [s for s in settings if s.get("queryShapeHash") == r["queryShapeHash"]] + entry = matching[0] if matching else {} + assertSuccessPartial( + entry.get("settings", {}), + test.build_expected(ctx), + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 7: Multi-setup settings management tests +# --------------------------------------------------------------------------- + + +SET_QUERY_SETTINGS_MULTI_SETUP_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "no_duplicate_on_update", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"dup1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"dup1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "classic", + }, + }, + ], + expected=lambda ctx: { + "ok": sum( + 1 + for h in ctx.setup_results[-1]["_live_hashes"] + if h + == [r["queryShapeHash"] for r in ctx.setup_results if "queryShapeHash" in r][-1] + ) + == 1 + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"dup1": 1}, + "$db": ctx.database, + } + } + ], + msg="updating same shape should not create duplicate entries", + ), + SettingsTestCase( + "multiple_settings_all_visible", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {f"multi{i}": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + for i in range(1, 4) + ], + expected=lambda ctx: { + "ok": all( + h in ctx.setup_results[-1]["_live_hashes"] + for h in [r["queryShapeHash"] for r in ctx.setup_results if "queryShapeHash" in r] + ) + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {f"multi{i}": 1}, + "$db": ctx.database, + } + } + for i in range(1, 4) + ], + msg="all 3 query settings should be visible in $querySettings", + ), + SettingsTestCase( + "remove_one_leaves_others", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rem1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rem2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rem1": 1}, + "$db": ctx.database, + } + }, + ], + expected=lambda ctx: { + "ok": [r["queryShapeHash"] for r in ctx.setup_results if "queryShapeHash" in r][0] + not in ctx.setup_results[-1]["_live_hashes"] + and [r["queryShapeHash"] for r in ctx.setup_results if "queryShapeHash" in r][1] + in ctx.setup_results[-1]["_live_hashes"] + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {f"rem{i}": 1}, + "$db": ctx.database, + } + } + for i in range(1, 3) + ], + msg="q1 removed, q2 should remain in $querySettings", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_MULTI_SETUP_TESTS)) +def test_setQuerySettings_multi_setup(collection, test): + """Test multi-setup settings management via $querySettings inspection.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + all_hashes = {s.get("queryShapeHash") for s in get_query_settings(collection)} + # Stash live hashes so expected-lambdas can reference them. + ctx.setup_results.append({"_live_hashes": all_hashes}) + assertSuccessPartial( + test.build_expected(ctx), + {"ok": True}, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/setQuerySettings/test_smoke_setQuerySettings.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_smoke_setQuerySettings.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/setQuerySettings/test_smoke_setQuerySettings.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_smoke_setQuerySettings.py diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/setQuerySettings_common.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/setQuerySettings_common.py new file mode 100644 index 000000000..9d5da0037 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/setQuerySettings_common.py @@ -0,0 +1,15 @@ +"""Shared utilities for setQuerySettings tests.""" + +from __future__ import annotations + +from typing import Any + +from pymongo.collection import Collection + + +def get_query_settings(collection: Collection) -> list[dict[str, Any]]: + """Retrieve all current query settings via $querySettings stage.""" + admin = collection.database.client.admin + result = admin.command({"aggregate": 1, "pipeline": [{"$querySettings": {}}], "cursor": {}}) + batch: list[dict[str, Any]] = result.get("cursor", {}).get("firstBatch", []) + return batch diff --git a/documentdb_tests/compatibility/tests/core/query_planning/utils/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/utils/settings_test_case.py b/documentdb_tests/compatibility/tests/core/query_planning/utils/settings_test_case.py new file mode 100644 index 000000000..9e8d565bc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/utils/settings_test_case.py @@ -0,0 +1,53 @@ +"""Test case with setup/cleanup lifecycle for settings-based commands. + +``SettingsTestCase`` extends ``CommandTestCase`` with ``setup_commands`` +and ``cleanup`` hooks for commands that require prerequisite operations +(e.g. creating a query setting before testing removal) and post-test +teardown (e.g. removing cluster-wide query settings). + +Results returned by each setup command are appended to +``setup_results`` so that later lambdas (``command``, ``expected``, +etc.) can reference values produced during setup. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) + + +@dataclass(frozen=True) +class SettingsTestCase(CommandTestCase): + """CommandTestCase with setup-command and cleanup lifecycle. + + Attributes: + setup_commands: Optional callable ``(CommandContext) -> list[dict]`` + returning commands to execute **before** the main command. + Each command's result is appended to + ``CommandContext.setup_results`` by the runner. + cleanup: Optional callable ``(CommandContext) -> list[dict]`` + returning commands to run after the test. Each dict is + passed to the executor inside a try/except so cleanup + failures are silently ignored. + """ + + setup_commands: Callable[[CommandContext], list[dict[str, Any]]] | None = None + cleanup: Callable[[CommandContext], list[dict[str, Any]]] | None = None + + def build_setup(self, ctx: CommandContext) -> list[dict[str, Any]]: + """Resolve setup commands from the callable, or return empty list.""" + if self.setup_commands is None: + return [] + return self.setup_commands(ctx) + + def build_cleanup(self, ctx: CommandContext) -> list[dict[str, Any]]: + """Resolve cleanup commands from the callable, or return empty list.""" + if self.cleanup is None: + return [] + return self.cleanup(ctx) diff --git a/documentdb_tests/compatibility/tests/core/utils/command_test_case.py b/documentdb_tests/compatibility/tests/core/utils/command_test_case.py index 82b193f27..c4785c54e 100644 --- a/documentdb_tests/compatibility/tests/core/utils/command_test_case.py +++ b/documentdb_tests/compatibility/tests/core/utils/command_test_case.py @@ -1,4 +1,4 @@ -"""Shared test case for collection command tests.""" +"""Shared test case for collection and admin command tests.""" from __future__ import annotations @@ -26,12 +26,16 @@ class CommandContext: database: The resolved database name. namespace: The full namespace string (``database.collection``). uuids: Mapping of collection names to their server-assigned UUIDs. + setup_results: Results from setup commands, populated by the runner. + Mutable even in a frozen dataclass so runners can append after + construction. """ collection: str database: str namespace: str uuids: dict[str, Any] = field(default_factory=dict) + setup_results: list[dict[str, Any]] = field(default_factory=list) @classmethod def from_collection(cls, collection: Collection) -> CommandContext: diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 15d5febb0..090249637 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -60,6 +60,7 @@ API_VERSION_ERROR = 322 API_STRICT_ERROR = 323 COLLECTION_UUID_MISMATCH_ERROR = 361 +QUERYSETTINGS_QUERY_REJECTED_ERROR = 411 EXPRESSION_NOT_OBJECT_ERROR = 10065 BSON_OBJECT_TOO_LARGE_ERROR = 10334 DUPLICATE_KEY_ERROR = 11000 @@ -514,11 +515,18 @@ N_ACCUMULATOR_INVALID_N_ERROR = 7548606 GEO_NEAR_MIN_DISTANCE_NOT_CONSTANT_ERROR = 7555701 GEO_NEAR_MAX_DISTANCE_NOT_CONSTANT_ERROR = 7555702 +QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR = 7746402 +QUERYSETTINGS_REJECT_ONLY_ERROR = 7746604 +QUERYSETTINGS_IDHACK_QUERY_ERROR = 7746606 QUERYSETTINGS_NON_DOCUMENT_ARG_ERROR = 7746800 PIPELINE_LENGTH_LIMIT_ERROR = 7749501 PERCENTILE_INVALID_P_FIELD_ERROR = 7750301 PERCENTILE_INVALID_P_VALUE_ERROR = 7750303 ENCRYPTED_FIELD_TRIM_FACTOR_OUT_OF_RANGE_ERROR = 8574000 +QUERYSETTINGS_INTERNAL_DB_ERROR = 8584900 +QUERYSETTINGS_NS_DB_MISSING_ERROR = 8727500 +QUERYSETTINGS_NS_COLL_MISSING_ERROR = 8727501 +QUERYSETTINGS_EMPTY_SETTINGS_ERROR = 8727502 COUNT_FIELD_ID_RESERVED_ERROR = 9039800 CONVERT_BYTE_ORDER_TYPE_ERROR = 9130001 CONVERT_BYTE_ORDER_VALUE_ERROR = 9130002 From 7a38ed42adce0f63d679b8396b198b783620951b Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:18:39 -0700 Subject: [PATCH 05/51] Add `profile` tests (#621) Signed-off-by: Alina (Xi) Li --- .../diagnostic/commands/profile/__init__.py | 0 .../profile/test_profile_combined_params.py | 92 ++++++ .../profile/test_profile_core_behavior.py | 177 ++++++++++ .../commands/profile/test_profile_errors.py | 145 ++++++++ .../commands/profile/test_profile_filter.py | 88 +++++ .../test_profile_response_structure.py | 80 +++++ .../profile/test_profile_samplerate.py | 91 +++++ .../commands/profile/test_profile_slowms.py | 99 ++++++ .../profile/test_profile_type_acceptance.py | 247 ++++++++++++++ .../profile/test_profile_type_errors.py | 310 ++++++++++++++++++ .../diagnostic/utils/diagnostic_test_case.py | 4 +- 11 files changed, 1332 insertions(+), 1 deletion(-) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_combined_params.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_filter.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_samplerate.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_slowms.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_type_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_type_errors.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_combined_params.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_combined_params.py new file mode 100644 index 000000000..3725854c7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_combined_params.py @@ -0,0 +1,92 @@ +"""Tests for profile command with multiple parameters and cross-database behavior. + +Validates setting multiple parameters simultaneously and cross-database +behavior. All tests in this file verify success cases only. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType + +pytestmark = [pytest.mark.no_parallel] + +# Property [Multi-Parameter Set]: setting level, slowms, sampleRate, and +# filter simultaneously applies all values. +MULTI_PARAM_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "level_slowms_samplerate", + setup=[{"profile": 1, "slowms": 50, "sampleRate": 0.5}], + command={"profile": -1}, + use_admin=False, + checks={"was": Eq(1), "slowms": Eq(50), "sampleRate": Eq(0.5)}, + msg="profile should apply level, slowms, and sampleRate together", + ), + DiagnosticTestCase( + "level_slowms_filter", + setup=[{"profile": 1, "slowms": 50, "filter": {"op": "query"}}], + command={"profile": -1}, + use_admin=False, + checks={"was": Eq(1), "slowms": Eq(50), "filter": Exists()}, + msg="profile should apply level, slowms, and filter together", + ), + DiagnosticTestCase( + "params_persist_when_profiling_disabled", + setup=[{"profile": 0, "slowms": 200, "sampleRate": 0.8}], + command={"profile": -1}, + use_admin=False, + checks={"was": Eq(0), "slowms": Eq(200), "sampleRate": Eq(0.8)}, + msg="profile should apply slowms and sampleRate even when profiler is off", + ), +] + +# Property [Database Scope]: profile operates on both regular and admin +# databases and returns the full response structure. +DATABASE_SCOPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "regular_database", + command={"profile": -1}, + use_admin=False, + checks={ + "was": IsType("int"), + "slowms": IsType("int"), + "sampleRate": IsType("double"), + "ok": Eq(1.0), + }, + msg="profile should return full response on a regular database", + ), + DiagnosticTestCase( + "admin_database", + command={"profile": -1}, + use_admin=True, + checks={ + "was": IsType("int"), + "slowms": IsType("int"), + "sampleRate": IsType("double"), + "ok": Eq(1.0), + }, + msg="profile should return full response on the admin database", + ), +] + +COMBINED_TESTS = MULTI_PARAM_TESTS + DATABASE_SCOPE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(COMBINED_TESTS)) +def test_profile_combined_params(collection, test): + """Test profile command with multiple parameters and database scope.""" + for cmd in test.setup: + execute_command(collection, cmd) + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + execute_command(collection, {"profile": 0, "slowms": 100, "sampleRate": 1.0, "filter": "unset"}) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_core_behavior.py new file mode 100644 index 000000000..cd43223fb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_core_behavior.py @@ -0,0 +1,177 @@ +"""Tests for profile command core behavior. + +Validates profiling level transitions, the 'was' field, out-of-range levels, +fractional level read-back, idempotency, and level persistence via read. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = [pytest.mark.no_parallel] + +# Property [Was Field Transitions]: the 'was' field returns the profiling +# level that was active before the current command. +WAS_TRANSITION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "was_0_to_1", + setup=[{"profile": 0}], + command={"profile": 1}, + checks={"was": Eq(0)}, + msg="'was' should be 0 after transition 0->1", + ), + DiagnosticTestCase( + "was_1_to_2", + setup=[{"profile": 1}], + command={"profile": 2}, + checks={"was": Eq(1)}, + msg="'was' should be 1 after transition 1->2", + ), + DiagnosticTestCase( + "was_2_to_0", + setup=[{"profile": 2}], + command={"profile": 0}, + checks={"was": Eq(2)}, + msg="'was' should be 2 after transition 2->0", + ), + DiagnosticTestCase( + "was_0_to_0", + setup=[{"profile": 0}], + command={"profile": 0}, + checks={"was": Eq(0)}, + msg="'was' should be 0 after transition 0->0", + ), +] + +# Property [Level Persistence via Read]: reading with -1 returns the current +# level in the 'was' field. +LEVEL_READ_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "read_level_0", + setup=[{"profile": 0}], + command={"profile": -1}, + checks={"was": Eq(0)}, + msg="'was' should be 0 when reading at level 0", + ), + DiagnosticTestCase( + "read_level_1", + setup=[{"profile": 1}], + command={"profile": -1}, + checks={"was": Eq(1)}, + msg="'was' should be 1 when reading at level 1", + ), + DiagnosticTestCase( + "read_level_2", + setup=[{"profile": 2}], + command={"profile": -1}, + checks={"was": Eq(2)}, + msg="'was' should be 2 when reading at level 2", + ), +] + +# Property [Out-of-Range Levels]: integer levels outside {-1, 0, 1, 2} +# succeed but do not change the profiling level. +OUT_OF_RANGE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "out_of_range_3", + setup=[{"profile": 1}, {"profile": 3}], + command={"profile": -1}, + checks={"was": Eq(1)}, + msg="profile level 3 should be a no-op", + ), + DiagnosticTestCase( + "out_of_range_neg2", + setup=[{"profile": 2}, {"profile": -2}], + command={"profile": -1}, + checks={"was": Eq(2)}, + msg="profile level -2 should be a no-op", + ), + DiagnosticTestCase( + "out_of_range_100", + setup=[{"profile": 0}, {"profile": 100}], + command={"profile": -1}, + checks={"was": Eq(0)}, + msg="profile level 100 should be a no-op", + ), +] + +# Property [Fractional Level Read-Back]: after setting a fractional level, +# reading with -1 returns the truncated integer level. +FRACTIONAL_READBACK_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "readback_1_5", + setup=[{"profile": 0}, {"profile": 1.5}], + command={"profile": -1}, + checks={"was": Eq(1)}, + msg="profile 1.5 should read back as level 1", + ), + DiagnosticTestCase( + "readback_0_5", + setup=[{"profile": 0}, {"profile": 0.5}], + command={"profile": -1}, + checks={"was": Eq(0)}, + msg="profile 0.5 should read back as level 0", + ), + DiagnosticTestCase( + "readback_2_9", + setup=[{"profile": 0}, {"profile": 2.9}], + command={"profile": -1}, + checks={"was": Eq(2)}, + msg="profile 2.9 should read back as level 2", + ), +] + +# Property [Idempotency]: repeated identical profile commands produce +# consistent results. +IDEMPOTENCY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "idempotent_set_0", + setup=[{"profile": 0}], + command={"profile": 0}, + checks={"was": Eq(0), "ok": Eq(1.0)}, + msg="Second profile 0 should show was=0", + ), +] + +CORE_BEHAVIOR_TESTS = ( + WAS_TRANSITION_TESTS + + LEVEL_READ_TESTS + + OUT_OF_RANGE_TESTS + + FRACTIONAL_READBACK_TESTS + + IDEMPOTENCY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CORE_BEHAVIOR_TESTS)) +def test_profile_core_behavior(collection, test): + """Test profile command core behavior.""" + for cmd in test.setup: + execute_command(collection, cmd) + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + execute_command(collection, {"profile": 0}) + + +def test_profile_idempotent_read(collection): + """Test running profile -1 twice returns identical results.""" + execute_command(collection, {"profile": 0}) + result1 = execute_command(collection, {"profile": -1}) + result2 = execute_command(collection, {"profile": -1}) + assertProperties( + result2, + { + "was": Eq(result1["was"]), + "slowms": Eq(result1["slowms"]), + "sampleRate": Eq(result1["sampleRate"]), + }, + msg="Two consecutive reads should return identical results", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_errors.py new file mode 100644 index 000000000..5ec5b8409 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_errors.py @@ -0,0 +1,145 @@ +"""Tests for profile command validation error cases. + +Validates sampleRate range rejection, filter value rejection, unrecognized +field rejection, and case-sensitive command name rejection. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + COMMAND_NOT_FOUND_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [sampleRate Range Rejection]: sampleRate values outside [0, 1] +# are rejected with BAD_VALUE_ERROR. +SAMPLERATE_RANGE_REJECTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "samplerate_neg_0_1", + command={"profile": 0, "sampleRate": -0.1}, + error_code=BAD_VALUE_ERROR, + msg="sampleRate should reject -0.1", + ), + DiagnosticTestCase( + "samplerate_1_1", + command={"profile": 0, "sampleRate": 1.1}, + error_code=BAD_VALUE_ERROR, + msg="sampleRate should reject 1.1", + ), + DiagnosticTestCase( + "samplerate_2_0", + command={"profile": 0, "sampleRate": 2.0}, + error_code=BAD_VALUE_ERROR, + msg="sampleRate should reject 2.0", + ), + DiagnosticTestCase( + "samplerate_neg_1_0", + command={"profile": 0, "sampleRate": -1.0}, + error_code=BAD_VALUE_ERROR, + msg="sampleRate should reject -1.0", + ), +] + +# Property [filter Type Rejection]: non-object, non-"unset" values for the +# filter field are rejected with BAD_VALUE_ERROR. +FILTER_REJECTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "filter_string_UNSET", + command={"profile": 1, "filter": "UNSET"}, + error_code=BAD_VALUE_ERROR, + msg="filter should reject 'UNSET' (case-sensitive)", + ), + DiagnosticTestCase( + "filter_string_Unset", + command={"profile": 1, "filter": "Unset"}, + error_code=BAD_VALUE_ERROR, + msg="filter should reject 'Unset' (case-sensitive)", + ), + DiagnosticTestCase( + "filter_empty_string", + command={"profile": 1, "filter": ""}, + error_code=BAD_VALUE_ERROR, + msg="filter should reject empty string", + ), + DiagnosticTestCase( + "filter_string_hello", + command={"profile": 1, "filter": "hello"}, + error_code=BAD_VALUE_ERROR, + msg="filter should reject arbitrary string", + ), + DiagnosticTestCase( + "filter_int", + command={"profile": 1, "filter": 1}, + error_code=BAD_VALUE_ERROR, + msg="filter should reject int", + ), + DiagnosticTestCase( + "filter_bool", + command={"profile": 1, "filter": True}, + error_code=BAD_VALUE_ERROR, + msg="filter should reject bool", + ), + DiagnosticTestCase( + "filter_null", + command={"profile": 1, "filter": None}, + error_code=BAD_VALUE_ERROR, + msg="filter should reject null", + ), + DiagnosticTestCase( + "filter_array", + command={"profile": 1, "filter": []}, + error_code=BAD_VALUE_ERROR, + msg="filter should reject array", + ), +] + +# Property [Unrecognized Fields]: unrecognized fields in the command document +# are rejected with UNRECOGNIZED_COMMAND_FIELD_ERROR. +UNRECOGNIZED_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "unrecognized_unknownField", + command={"profile": 0, "unknownField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="profile should reject unrecognized field 'unknownField'", + ), + DiagnosticTestCase( + "unrecognized_extraParam", + command={"profile": 0, "extraParam": "value"}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="profile should reject unrecognized field 'extraParam'", + ), +] + +# Property [Case-Sensitive Command Name]: the command name is case-sensitive; +# mismatched case produces COMMAND_NOT_FOUND_ERROR. +CASE_SENSITIVITY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "case_Profile", + command={"Profile": 0}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="'Profile' (capital P) should not be recognized", + ), +] + +PROFILE_ERROR_TESTS = ( + SAMPLERATE_RANGE_REJECTION_TESTS + + FILTER_REJECTION_TESTS + + UNRECOGNIZED_FIELD_TESTS + + CASE_SENSITIVITY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(PROFILE_ERROR_TESTS)) +def test_profile_errors(collection, test): + """Test profile command rejects invalid inputs with correct error codes.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_filter.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_filter.py new file mode 100644 index 000000000..4e7a607e0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_filter.py @@ -0,0 +1,88 @@ +"""Tests for profile command filter parameter. + +Validates filter object acceptance, the special "unset" string, filter +normalization, and the set/unset lifecycle. All tests in this file verify +success cases only. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, NotExists + +pytestmark = [pytest.mark.no_parallel] + +# Property [Filter Object Acceptance]: the filter field accepts object values +# and the filter is actually applied. +FILTER_ACCEPT_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "empty_object", + setup=[{"profile": 1, "filter": {}}], + command={"profile": -1}, + checks={"filter": Exists()}, + msg="filter should accept an empty object and be visible", + ), + DiagnosticTestCase( + "op_query", + setup=[{"profile": 1, "filter": {"op": "query"}}], + command={"profile": -1}, + checks={"filter": Exists()}, + msg="filter should accept {op: 'query'} and be visible", + ), + DiagnosticTestCase( + "op_in", + setup=[{"profile": 1, "filter": {"op": {"$in": ["query", "command"]}}}], + command={"profile": -1}, + checks={"filter": Exists()}, + msg="filter should accept $in expression and be visible", + ), + DiagnosticTestCase( + "ns_filter", + setup=[{"profile": 1, "filter": {"ns": "test.users"}}], + command={"profile": -1}, + checks={"filter": Exists()}, + msg="filter should accept namespace filter and be visible", + ), + DiagnosticTestCase( + "unset_removes_filter", + setup=[ + {"profile": 1, "filter": {"op": "query"}}, + {"profile": 1, "filter": "unset"}, + ], + command={"profile": -1}, + checks={"filter": NotExists()}, + msg="filter should accept 'unset' and remove the filter", + ), +] + + +# Property [Filter Normalization]: simple equality in the filter is normalized +# to $eq form in the response. +FILTER_NORMALIZATION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "normalize_eq", + setup=[{"profile": 1, "filter": {"op": "query"}}], + command={"profile": -1}, + checks={"filter": Eq({"op": {"$eq": "query"}})}, + msg="filter should normalize {op: 'query'} to {op: {$eq: 'query'}}", + ), +] + +FILTER_TESTS = FILTER_ACCEPT_TESTS + FILTER_NORMALIZATION_TESTS + + +@pytest.mark.parametrize("test", pytest_params(FILTER_TESTS)) +def test_profile_filter(collection, test): + """Test profile filter parameter behavior.""" + for cmd in test.setup: + execute_command(collection, cmd) + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + execute_command(collection, {"profile": 0, "filter": "unset"}) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_response_structure.py new file mode 100644 index 000000000..174459877 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_response_structure.py @@ -0,0 +1,80 @@ +"""Tests for profile command response structure. + +Validates presence, types, and values of response fields returned by the +profile command, including base fields and filter-related fields. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, NotExists + +pytestmark = [pytest.mark.no_parallel] + +# Property [Base Response Fields]: every successful profile command response +# includes was (int), slowms (int), sampleRate (double), and ok (1.0). +RESPONSE_BASE_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "base_fields_at_level_neg1", + command={"profile": -1}, + checks={ + "was": IsType("int"), + "slowms": IsType("int"), + "sampleRate": IsType("double"), + "ok": Eq(1.0), + }, + msg="profile -1 response should include was, slowms, sampleRate, and ok", + ), + DiagnosticTestCase( + "base_fields_at_level_0", + command={"profile": 0}, + checks={ + "was": IsType("int"), + "slowms": IsType("int"), + "sampleRate": IsType("double"), + "ok": Eq(1.0), + }, + msg="profile 0 response should include was, slowms, sampleRate, and ok", + ), +] + +# Property [Filter Response Fields]: when a filter is active the response +# includes filter and note; after unset they are absent. +RESPONSE_FILTER_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "filter_fields_present", + setup=[{"profile": 1, "filter": {"op": "query"}}], + command={"profile": -1}, + checks={"filter": Exists(), "note": IsType("string")}, + msg="profile response should include filter and note when a filter is active", + ), + DiagnosticTestCase( + "filter_fields_absent_after_unset", + setup=[ + {"profile": 1, "filter": {"op": "query"}}, + {"profile": 1, "filter": "unset"}, + ], + command={"profile": -1}, + checks={"filter": NotExists(), "note": NotExists()}, + msg="profile response should exclude filter and note after filter is unset", + ), +] + +RESPONSE_STRUCTURE_TESTS = RESPONSE_BASE_FIELD_TESTS + RESPONSE_FILTER_FIELD_TESTS + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_STRUCTURE_TESTS)) +def test_profile_response_structure(collection, test): + """Test profile response field presence and types.""" + for cmd in test.setup: + execute_command(collection, cmd) + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + execute_command(collection, {"profile": 0, "filter": "unset"}) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_samplerate.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_samplerate.py new file mode 100644 index 000000000..930e4c36e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_samplerate.py @@ -0,0 +1,91 @@ +"""Tests for profile command sampleRate parameter. + +Validates sampleRate value persistence, boundary values, and null no-op behavior. +All tests in this file verify success cases only. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = [pytest.mark.no_parallel] + +# Property [sampleRate Persistence]: setting sampleRate persists the value +# for subsequent reads. +SAMPLERATE_PERSISTENCE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "persist_0_5", + setup=[{"profile": 0, "sampleRate": 0.5}], + command={"profile": -1}, + checks={"sampleRate": Eq(0.5)}, + msg="sampleRate should persist value 0.5", + ), + DiagnosticTestCase( + "persist_boundary_zero", + setup=[{"profile": 0, "sampleRate": 0.0}], + command={"profile": -1}, + checks={"sampleRate": Eq(0.0)}, + msg="sampleRate should persist value 0.0", + ), + DiagnosticTestCase( + "persist_1_0", + setup=[{"profile": 0, "sampleRate": 1.0}], + command={"profile": -1}, + checks={"sampleRate": Eq(1.0)}, + msg="sampleRate should persist value 1.0", + ), +] + +# Property [sampleRate Boundary Acceptance]: sampleRate accepts boundary +# values within [0, 1] using int and double types. +SAMPLERATE_BOUNDARY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "boundary_int_0", + setup=[{"profile": 0, "sampleRate": 0}], + command={"profile": -1}, + checks={"sampleRate": Eq(0.0)}, + msg="sampleRate should accept int 0", + ), + DiagnosticTestCase( + "boundary_int_1", + setup=[{"profile": 0, "sampleRate": 1}], + command={"profile": -1}, + checks={"sampleRate": Eq(1.0)}, + msg="sampleRate should accept int 1", + ), +] + +# Property [sampleRate Null No-Op]: setting sampleRate to null does not +# change the current value. +SAMPLERATE_NULL_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "null_noop", + setup=[ + {"profile": 0, "sampleRate": 0.5}, + {"profile": 0, "sampleRate": None}, + ], + command={"profile": -1}, + checks={"sampleRate": Eq(0.5)}, + msg="sampleRate should remain 0.5 after setting null", + ), +] + +SAMPLERATE_TESTS = SAMPLERATE_PERSISTENCE_TESTS + SAMPLERATE_BOUNDARY_TESTS + SAMPLERATE_NULL_TESTS + + +@pytest.mark.parametrize("test", pytest_params(SAMPLERATE_TESTS)) +def test_profile_samplerate(collection, test): + """Test profile sampleRate parameter behavior.""" + for cmd in test.setup: + execute_command(collection, cmd) + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + execute_command(collection, {"profile": 0, "sampleRate": 1.0}) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_slowms.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_slowms.py new file mode 100644 index 000000000..9d917ac9b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_slowms.py @@ -0,0 +1,99 @@ +"""Tests for profile command slowms parameter. + +Validates slowms value persistence, boundary values, and null no-op behavior. +All tests in this file verify success cases only. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import INT32_MAX, INT32_MIN + +pytestmark = [pytest.mark.no_parallel] + +# Property [slowms Persistence]: setting slowms persists the value for +# subsequent reads. +SLOWMS_PERSISTENCE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "persist_200", + setup=[{"profile": 0, "slowms": 200}], + command={"profile": -1}, + checks={"slowms": Eq(200)}, + msg="slowms should persist value 200", + ), + DiagnosticTestCase( + "persist_0", + setup=[{"profile": 0, "slowms": 0}], + command={"profile": -1}, + checks={"slowms": Eq(0)}, + msg="slowms should persist value 0", + ), + DiagnosticTestCase( + "persist_negative_value", + setup=[{"profile": 0, "slowms": -1}], + command={"profile": -1}, + checks={"slowms": Eq(-1)}, + msg="slowms should persist negative value -1", + ), +] + +# Property [slowms Boundary Values]: slowms accepts the full int32 range +# including negative values. +SLOWMS_BOUNDARY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "boundary_int32_max", + setup=[{"profile": 0, "slowms": INT32_MAX}], + command={"profile": -1}, + checks={"slowms": Eq(INT32_MAX)}, + msg="slowms should accept INT32_MAX", + ), + DiagnosticTestCase( + "boundary_int32_min", + setup=[{"profile": 0, "slowms": INT32_MIN}], + command={"profile": -1}, + checks={"slowms": Eq(INT32_MIN)}, + msg="slowms should accept INT32_MIN", + ), + DiagnosticTestCase( + "boundary_neg100", + setup=[{"profile": 0, "slowms": -100}], + command={"profile": -1}, + checks={"slowms": Eq(-100)}, + msg="slowms should accept -100", + ), +] + +# Property [slowms Null No-Op]: setting slowms to null does not change +# the current value. +SLOWMS_NULL_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "null_noop", + setup=[ + {"profile": 0, "slowms": 500}, + {"profile": 0, "slowms": None}, + ], + command={"profile": -1}, + checks={"slowms": Eq(500)}, + msg="slowms should remain 500 after setting null", + ), +] + +SLOWMS_TESTS = SLOWMS_PERSISTENCE_TESTS + SLOWMS_BOUNDARY_TESTS + SLOWMS_NULL_TESTS + + +@pytest.mark.parametrize("test", pytest_params(SLOWMS_TESTS)) +def test_profile_slowms(collection, test): + """Test profile slowms parameter behavior.""" + for cmd in test.setup: + execute_command(collection, cmd) + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + execute_command(collection, {"profile": 0, "slowms": 100}) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_type_acceptance.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_type_acceptance.py new file mode 100644 index 000000000..a987c03af --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_type_acceptance.py @@ -0,0 +1,247 @@ +"""Tests for profile command type acceptance. + +Validates that the profile, slowms, and sampleRate fields accept all valid +numeric BSON types and that the values are actually applied. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gte + +pytestmark = [pytest.mark.no_parallel] + +# Property [Profile Field Type Acceptance]: the profile field accepts int, +# Int64, double, and Decimal128 for valid level values, and the level is +# actually applied. +PROFILE_TYPE_ACCEPTANCE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "profile_int_0", + setup=[{"profile": 1}, {"profile": 0}], + command={"profile": -1}, + checks={"was": Eq(0)}, + msg="profile should accept int 0 and apply level", + ), + DiagnosticTestCase( + "profile_int_1", + setup=[{"profile": 0}, {"profile": 1}], + command={"profile": -1}, + checks={"was": Eq(1)}, + msg="profile should accept int 1 and apply level", + ), + DiagnosticTestCase( + "profile_int_2", + setup=[{"profile": 0}, {"profile": 2}], + command={"profile": -1}, + checks={"was": Eq(2)}, + msg="profile should accept int 2 and apply level", + ), + DiagnosticTestCase( + "profile_int_neg1", + setup=[{"profile": 1}], + command={"profile": -1}, + checks={"was": Eq(1)}, + msg="profile should accept int -1 and read current level", + ), + DiagnosticTestCase( + "profile_int64_0", + setup=[{"profile": 1}, {"profile": Int64(0)}], + command={"profile": -1}, + checks={"was": Eq(0)}, + msg="profile should accept Int64(0) and apply level", + ), + DiagnosticTestCase( + "profile_int64_1", + setup=[{"profile": 0}, {"profile": Int64(1)}], + command={"profile": -1}, + checks={"was": Eq(1)}, + msg="profile should accept Int64(1) and apply level", + ), + DiagnosticTestCase( + "profile_int64_2", + setup=[{"profile": 0}, {"profile": Int64(2)}], + command={"profile": -1}, + checks={"was": Eq(2)}, + msg="profile should accept Int64(2) and apply level", + ), + DiagnosticTestCase( + "profile_double_0", + setup=[{"profile": 1}, {"profile": 0.0}], + command={"profile": -1}, + checks={"was": Eq(0)}, + msg="profile should accept double 0.0 and apply level", + ), + DiagnosticTestCase( + "profile_double_1", + setup=[{"profile": 0}, {"profile": 1.0}], + command={"profile": -1}, + checks={"was": Eq(1)}, + msg="profile should accept double 1.0 and apply level", + ), + DiagnosticTestCase( + "profile_double_2", + setup=[{"profile": 0}, {"profile": 2.0}], + command={"profile": -1}, + checks={"was": Eq(2)}, + msg="profile should accept double 2.0 and apply level", + ), + DiagnosticTestCase( + "profile_double_neg1", + setup=[{"profile": 1}], + command={"profile": -1.0}, + checks={"was": Eq(1)}, + msg="profile should accept double -1.0 and read current level", + ), + DiagnosticTestCase( + "profile_decimal128_0", + setup=[{"profile": 1}, {"profile": Decimal128("0")}], + command={"profile": -1}, + checks={"was": Eq(0)}, + msg="profile should accept Decimal128('0') and apply level", + ), + DiagnosticTestCase( + "profile_decimal128_1", + setup=[{"profile": 0}, {"profile": Decimal128("1")}], + command={"profile": -1}, + checks={"was": Eq(1)}, + msg="profile should accept Decimal128('1') and apply level", + ), + DiagnosticTestCase( + "profile_decimal128_2", + setup=[{"profile": 0}, {"profile": Decimal128("2")}], + command={"profile": -1}, + checks={"was": Eq(2)}, + msg="profile should accept Decimal128('2') and apply level", + ), +] + +# Property [slowms Type Acceptance]: the slowms field accepts numeric BSON +# types (int, Int64, double, Decimal128) and null, and the value is applied. +SLOWMS_TYPE_ACCEPTANCE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "slowms_int_100", + setup=[{"profile": 0, "slowms": 100}], + command={"profile": -1}, + checks={"slowms": Eq(100)}, + msg="slowms should accept int 100 and persist value", + ), + DiagnosticTestCase( + "slowms_int64_100", + setup=[{"profile": 0, "slowms": Int64(100)}], + command={"profile": -1}, + checks={"slowms": Eq(100)}, + msg="slowms should accept Int64(100) and persist value", + ), + DiagnosticTestCase( + "slowms_double_100", + setup=[{"profile": 0, "slowms": 100.0}], + command={"profile": -1}, + checks={"slowms": Eq(100)}, + msg="slowms should accept double 100.0 and persist value", + ), + DiagnosticTestCase( + "slowms_decimal128_100", + setup=[{"profile": 0, "slowms": Decimal128("100")}], + command={"profile": -1}, + checks={"slowms": Eq(100)}, + msg="slowms should accept Decimal128('100') and persist value", + ), + DiagnosticTestCase( + "slowms_null", + command={"profile": 0, "slowms": None}, + checks={"ok": Eq(1.0)}, + msg="slowms should accept null (no-op)", + ), +] + +# Property [sampleRate Type Acceptance]: the sampleRate field accepts numeric +# BSON types (int, Int64, double, Decimal128) within [0, 1] and null, and the +# value is applied. +SAMPLERATE_TYPE_ACCEPTANCE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "samplerate_double_0_5", + setup=[{"profile": 0, "sampleRate": 0.5}], + command={"profile": -1}, + checks={"sampleRate": Eq(0.5)}, + msg="sampleRate should accept double 0.5 and persist value", + ), + DiagnosticTestCase( + "samplerate_int_0", + setup=[{"profile": 0, "sampleRate": 0}], + command={"profile": -1}, + checks={"sampleRate": Eq(0.0)}, + msg="sampleRate should accept int 0 and persist value", + ), + DiagnosticTestCase( + "samplerate_int_1", + setup=[{"profile": 0, "sampleRate": 1}], + command={"profile": -1}, + checks={"sampleRate": Eq(1.0)}, + msg="sampleRate should accept int 1 and persist value", + ), + DiagnosticTestCase( + "samplerate_int64_0", + setup=[{"profile": 0, "sampleRate": Int64(0)}], + command={"profile": -1}, + checks={"sampleRate": Eq(0.0)}, + msg="sampleRate should accept Int64(0) and persist value", + ), + DiagnosticTestCase( + "samplerate_int64_1", + setup=[{"profile": 0, "sampleRate": Int64(1)}], + command={"profile": -1}, + checks={"sampleRate": Eq(1.0)}, + msg="sampleRate should accept Int64(1) and persist value", + ), + # Decimal128("0") converts to a near-zero double (~2.225e-308), not exactly 0.0, + # so we use Gte(0.0) instead of Eq(0.0). + DiagnosticTestCase( + "samplerate_decimal128_0", + setup=[{"profile": 0, "sampleRate": Decimal128("0")}], + command={"profile": -1}, + checks={"sampleRate": Gte(0.0)}, + msg="sampleRate should accept Decimal128('0') and persist value", + ), + DiagnosticTestCase( + "samplerate_decimal128_0_5", + setup=[{"profile": 0, "sampleRate": Decimal128("0.5")}], + command={"profile": -1}, + checks={"sampleRate": Eq(0.5)}, + msg="sampleRate should accept Decimal128('0.5') and persist value", + ), + DiagnosticTestCase( + "samplerate_decimal128_1", + setup=[{"profile": 0, "sampleRate": Decimal128("1")}], + command={"profile": -1}, + checks={"sampleRate": Eq(1.0)}, + msg="sampleRate should accept Decimal128('1') and persist value", + ), + DiagnosticTestCase( + "samplerate_null", + command={"profile": 0, "sampleRate": None}, + checks={"ok": Eq(1.0)}, + msg="sampleRate should accept null (no-op)", + ), +] + +TYPE_ACCEPTANCE_TESTS = ( + PROFILE_TYPE_ACCEPTANCE_TESTS + SLOWMS_TYPE_ACCEPTANCE_TESTS + SAMPLERATE_TYPE_ACCEPTANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(TYPE_ACCEPTANCE_TESTS)) +def test_profile_type_acceptance(collection, test): + """Test profile command field type acceptance with readback verification.""" + for cmd in test.setup: + execute_command(collection, cmd) + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + execute_command(collection, {"profile": 0, "slowms": 100, "sampleRate": 1.0}) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_type_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_type_errors.py new file mode 100644 index 000000000..638d0719f --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/profile/test_profile_type_errors.py @@ -0,0 +1,310 @@ +"""Tests for profile command BSON type rejection. + +Validates that non-numeric types for the profile, slowms, and sampleRate +fields are rejected with TYPE_MISMATCH_ERROR, and that null for the required +profile field produces MISSING_FIELD_ERROR. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Profile Type Rejection]: non-numeric types for the profile field +# are rejected with TYPE_MISMATCH_ERROR. +PROFILE_TYPE_REJECTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "profile_bool_true", + command={"profile": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject bool true", + ), + DiagnosticTestCase( + "profile_bool_false", + command={"profile": False}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject bool false", + ), + DiagnosticTestCase( + "profile_string", + command={"profile": "hello"}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject string", + ), + DiagnosticTestCase( + "profile_array", + command={"profile": [1]}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject array", + ), + DiagnosticTestCase( + "profile_empty_array", + command={"profile": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject empty array", + ), + DiagnosticTestCase( + "profile_object", + command={"profile": {"a": 1}}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject object", + ), + DiagnosticTestCase( + "profile_empty_object", + command={"profile": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject empty object", + ), + DiagnosticTestCase( + "profile_objectid", + command={"profile": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject ObjectId", + ), + DiagnosticTestCase( + "profile_binary", + command={"profile": Binary(b"")}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject Binary", + ), + DiagnosticTestCase( + "profile_regex", + command={"profile": Regex("test")}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject Regex", + ), + DiagnosticTestCase( + "profile_timestamp", + command={"profile": Timestamp(0, 0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject Timestamp", + ), + DiagnosticTestCase( + "profile_code", + command={"profile": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject Code", + ), + DiagnosticTestCase( + "profile_datetime", + command={"profile": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject datetime", + ), + DiagnosticTestCase( + "profile_minkey", + command={"profile": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject MinKey", + ), + DiagnosticTestCase( + "profile_maxkey", + command={"profile": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="profile should reject MaxKey", + ), +] + +# Property [Profile Null Rejection]: null for the required profile field +# produces MISSING_FIELD_ERROR. +PROFILE_NULL_REJECTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "profile_null", + command={"profile": None}, + error_code=MISSING_FIELD_ERROR, + msg="profile should reject null with missing field error", + ), +] + +# Property [slowms Type Rejection]: non-numeric types for the slowms field +# are rejected with TYPE_MISMATCH_ERROR. +SLOWMS_TYPE_REJECTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "slowms_string", + command={"profile": 0, "slowms": "hello"}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject string", + ), + DiagnosticTestCase( + "slowms_bool_true", + command={"profile": 0, "slowms": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject bool true", + ), + DiagnosticTestCase( + "slowms_bool_false", + command={"profile": 0, "slowms": False}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject bool false", + ), + DiagnosticTestCase( + "slowms_array", + command={"profile": 0, "slowms": [1]}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject array", + ), + DiagnosticTestCase( + "slowms_object", + command={"profile": 0, "slowms": {"a": 1}}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject object", + ), + DiagnosticTestCase( + "slowms_objectid", + command={"profile": 0, "slowms": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject ObjectId", + ), + DiagnosticTestCase( + "slowms_regex", + command={"profile": 0, "slowms": Regex("test")}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject Regex", + ), + DiagnosticTestCase( + "slowms_timestamp", + command={"profile": 0, "slowms": Timestamp(0, 0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject Timestamp", + ), + DiagnosticTestCase( + "slowms_binary", + command={"profile": 0, "slowms": Binary(b"")}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject Binary", + ), + DiagnosticTestCase( + "slowms_code", + command={"profile": 0, "slowms": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject Code", + ), + DiagnosticTestCase( + "slowms_minkey", + command={"profile": 0, "slowms": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject MinKey", + ), + DiagnosticTestCase( + "slowms_maxkey", + command={"profile": 0, "slowms": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject MaxKey", + ), + DiagnosticTestCase( + "slowms_datetime", + command={"profile": 0, "slowms": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=TYPE_MISMATCH_ERROR, + msg="slowms should reject datetime", + ), +] + +# Property [sampleRate Type Rejection]: non-numeric types for the sampleRate +# field are rejected with TYPE_MISMATCH_ERROR. +SAMPLERATE_TYPE_REJECTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "samplerate_string", + command={"profile": 0, "sampleRate": "hello"}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject string", + ), + DiagnosticTestCase( + "samplerate_bool_true", + command={"profile": 0, "sampleRate": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject bool true", + ), + DiagnosticTestCase( + "samplerate_bool_false", + command={"profile": 0, "sampleRate": False}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject bool false", + ), + DiagnosticTestCase( + "samplerate_array", + command={"profile": 0, "sampleRate": [1]}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject array", + ), + DiagnosticTestCase( + "samplerate_object", + command={"profile": 0, "sampleRate": {"a": 1}}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject object", + ), + DiagnosticTestCase( + "samplerate_objectid", + command={"profile": 0, "sampleRate": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject ObjectId", + ), + DiagnosticTestCase( + "samplerate_regex", + command={"profile": 0, "sampleRate": Regex("test")}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject Regex", + ), + DiagnosticTestCase( + "samplerate_timestamp", + command={"profile": 0, "sampleRate": Timestamp(0, 0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject Timestamp", + ), + DiagnosticTestCase( + "samplerate_binary", + command={"profile": 0, "sampleRate": Binary(b"")}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject Binary", + ), + DiagnosticTestCase( + "samplerate_code", + command={"profile": 0, "sampleRate": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject Code", + ), + DiagnosticTestCase( + "samplerate_minkey", + command={"profile": 0, "sampleRate": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject MinKey", + ), + DiagnosticTestCase( + "samplerate_maxkey", + command={"profile": 0, "sampleRate": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject MaxKey", + ), + DiagnosticTestCase( + "samplerate_datetime", + command={"profile": 0, "sampleRate": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=TYPE_MISMATCH_ERROR, + msg="sampleRate should reject datetime", + ), +] + +PROFILE_TYPE_ERROR_TESTS = ( + PROFILE_TYPE_REJECTION_TESTS + + PROFILE_NULL_REJECTION_TESTS + + SLOWMS_TYPE_REJECTION_TESTS + + SAMPLERATE_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(PROFILE_TYPE_ERROR_TESTS)) +def test_profile_type_errors(collection, test): + """Test profile command rejects non-numeric types with correct error codes.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py b/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py index 39adb13d7..3d08d60c0 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py @@ -1,7 +1,7 @@ """Shared test case for diagnostic command tests.""" from dataclasses import dataclass, field -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from documentdb_tests.framework.test_case import BaseTestCase @@ -11,11 +11,13 @@ class DiagnosticTestCase(BaseTestCase): """Test case for diagnostic command tests. Attributes: + setup: Commands to run before the test command to establish state. command: The command document to execute. use_admin: If True, execute against the admin database. checks: Mapping of dotted field paths to property check objects. """ + setup: List[Dict[str, Any]] = field(default_factory=list) command: Optional[Dict[str, Any]] = None use_admin: bool = True checks: Dict[str, Any] = field(default_factory=dict) From 7f044f5447ee1fe692b491317798a54a5dda6dce Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:29:39 -0700 Subject: [PATCH 06/51] Add `serverStatus` tests (#623) Signed-off-by: Alina (Xi) Li --- .../commands/serverStatus/__init__.py | 0 .../test_serverStatus_argument_handling.py | 172 ++++++++++ .../test_serverStatus_core_behavior.py | 257 +++++++++++++++ .../test_serverStatus_error_conditions.py | 40 +++ .../test_serverStatus_field_toggle.py | 214 ++++++++++++ .../test_serverStatus_response_structure.py | 311 ++++++++++++++++++ 6 files changed, 994 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_field_toggle.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_argument_handling.py new file mode 100644 index 000000000..9fa765003 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_argument_handling.py @@ -0,0 +1,172 @@ +"""Tests for serverStatus command argument handling. + +Validates that serverStatus accepts any BSON type as its argument value. +The command field value is ignored by serverStatus. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +# Property [BSON Type Acceptance]: serverStatus accepts any BSON type as the command argument value. +ARGUMENT_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="int_1", + command={"serverStatus": 1}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept int 1 as argument value", + ), + DiagnosticTestCase( + id="int_0", + command={"serverStatus": 0}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept int 0 as argument value", + ), + DiagnosticTestCase( + id="int_neg1", + command={"serverStatus": -1}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept negative int as argument value", + ), + DiagnosticTestCase( + id="bool_true", + command={"serverStatus": True}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept boolean true as argument value", + ), + DiagnosticTestCase( + id="bool_false", + command={"serverStatus": False}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept boolean false as argument value", + ), + DiagnosticTestCase( + id="string", + command={"serverStatus": "hello"}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept string as argument value", + ), + DiagnosticTestCase( + id="empty_string", + command={"serverStatus": ""}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept empty string as argument value", + ), + DiagnosticTestCase( + id="null", + command={"serverStatus": None}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept null as argument value", + ), + DiagnosticTestCase( + id="empty_object", + command={"serverStatus": {}}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept empty object as argument value", + ), + DiagnosticTestCase( + id="empty_array", + command={"serverStatus": []}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept empty array as argument value", + ), + DiagnosticTestCase( + id="double", + command={"serverStatus": 1.5}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept double as argument value", + ), + DiagnosticTestCase( + id="int64", + command={"serverStatus": Int64(1)}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept int64 as argument value", + ), + DiagnosticTestCase( + id="decimal128", + command={"serverStatus": Decimal128("1")}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept Decimal128 as argument value", + ), + DiagnosticTestCase( + id="decimal128_nan", + command={"serverStatus": Decimal128("NaN")}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept Decimal128 NaN as argument value", + ), + DiagnosticTestCase( + id="infinity", + command={"serverStatus": float("inf")}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept infinity as argument value", + ), + DiagnosticTestCase( + id="date", + command={"serverStatus": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept datetime as argument value", + ), + DiagnosticTestCase( + id="binData", + command={"serverStatus": Binary(b"")}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept Binary as argument value", + ), + DiagnosticTestCase( + id="objectId", + command={"serverStatus": ObjectId()}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept ObjectId as argument value", + ), + DiagnosticTestCase( + id="regex", + command={"serverStatus": Regex("test")}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept Regex as argument value", + ), + DiagnosticTestCase( + id="timestamp", + command={"serverStatus": Timestamp(0, 0)}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept Timestamp as argument value", + ), + DiagnosticTestCase( + id="minKey", + command={"serverStatus": MinKey()}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept MinKey as argument value", + ), + DiagnosticTestCase( + id="maxKey", + command={"serverStatus": MaxKey()}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept MaxKey as argument value", + ), + DiagnosticTestCase( + id="code", + command={"serverStatus": Code("function(){}")}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept JavaScript Code as argument value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_TYPE_TESTS)) +def test_serverStatus_argument_types(collection, test): + """Test that serverStatus accepts various BSON types as argument value.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_core_behavior.py new file mode 100644 index 000000000..659d79235 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_core_behavior.py @@ -0,0 +1,257 @@ +"""Tests for serverStatus command core behavior. + +Validates semantic correctness of response field values, non-negative +counters, and cross-field consistency. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gt, Gte + +pytestmark = pytest.mark.admin + + +# Property [Positive Values]: serverStatus fields have expected positive or non-negative bounds. +POSITIVE_VALUE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="uptime_gte_0", + checks={"uptime": Gte(0)}, + msg="serverStatus should return uptime >= 0", + ), + DiagnosticTestCase( + id="uptimeMillis_gte_0", + checks={"uptimeMillis": Gte(0)}, + msg="serverStatus should return uptimeMillis >= 0", + ), + DiagnosticTestCase( + id="uptimeEstimate_gte_0", + checks={"uptimeEstimate": Gte(0)}, + msg="serverStatus should return uptimeEstimate >= 0", + ), + DiagnosticTestCase( + id="pid_gt_0", + checks={"pid": Gt(0)}, + msg="serverStatus should return pid > 0", + ), + DiagnosticTestCase( + id="connections_current_gte_1", + checks={"connections.current": Gte(1)}, + msg="serverStatus should report at least one current connection", + ), + DiagnosticTestCase( + id="connections_available_gte_0", + checks={"connections.available": Gte(0)}, + msg="serverStatus should return connections.available >= 0", + ), + DiagnosticTestCase( + id="connections_totalCreated_gte_1", + checks={"connections.totalCreated": Gte(1)}, + msg="serverStatus should report at least one created connection", + ), + DiagnosticTestCase( + id="mem_resident_gt_0", + checks={"mem.resident": Gt(0)}, + msg="serverStatus should report positive resident memory usage", + ), + DiagnosticTestCase( + id="mem_virtual_gt_0", + checks={"mem.virtual": Gt(0)}, + msg="serverStatus should report positive virtual memory usage", + ), + DiagnosticTestCase( + id="globalLock_totalTime_gte_0", + checks={"globalLock.totalTime": Gte(0)}, + msg="serverStatus should return globalLock.totalTime >= 0", + ), + DiagnosticTestCase( + id="process_is_mongod", + checks={"process": Eq("mongod")}, + msg="serverStatus should return process as mongod on a mongod instance", + ), +] + +# Property [Non-Negative Counters]: serverStatus counter fields are non-negative integers. +COUNTER_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="asserts_regular_gte_0", + checks={"asserts.regular": Gte(0)}, + msg="serverStatus should return asserts.regular >= 0", + ), + DiagnosticTestCase( + id="asserts_warning_gte_0", + checks={"asserts.warning": Gte(0)}, + msg="serverStatus should return asserts.warning >= 0", + ), + DiagnosticTestCase( + id="asserts_msg_gte_0", + checks={"asserts.msg": Gte(0)}, + msg="serverStatus should return asserts.msg >= 0", + ), + DiagnosticTestCase( + id="asserts_user_gte_0", + checks={"asserts.user": Gte(0)}, + msg="serverStatus should return asserts.user >= 0", + ), + DiagnosticTestCase( + id="asserts_rollovers_gte_0", + checks={"asserts.rollovers": Gte(0)}, + msg="serverStatus should return asserts.rollovers >= 0", + ), + DiagnosticTestCase( + id="opcounters_insert_gte_0", + checks={"opcounters.insert": Gte(0)}, + msg="serverStatus should return opcounters.insert >= 0", + ), + DiagnosticTestCase( + id="opcounters_query_gte_0", + checks={"opcounters.query": Gte(0)}, + msg="serverStatus should return opcounters.query >= 0", + ), + DiagnosticTestCase( + id="opcounters_update_gte_0", + checks={"opcounters.update": Gte(0)}, + msg="serverStatus should return opcounters.update >= 0", + ), + DiagnosticTestCase( + id="opcounters_delete_gte_0", + checks={"opcounters.delete": Gte(0)}, + msg="serverStatus should return opcounters.delete >= 0", + ), + DiagnosticTestCase( + id="opcounters_getmore_gte_0", + checks={"opcounters.getmore": Gte(0)}, + msg="serverStatus should return opcounters.getmore >= 0", + ), + DiagnosticTestCase( + id="opcounters_command_gte_0", + checks={"opcounters.command": Gte(0)}, + msg="serverStatus should return opcounters.command >= 0", + ), + DiagnosticTestCase( + id="network_bytesIn_gte_0", + checks={"network.bytesIn": Gte(0)}, + msg="serverStatus should return network.bytesIn >= 0", + ), + DiagnosticTestCase( + id="network_bytesOut_gte_0", + checks={"network.bytesOut": Gte(0)}, + msg="serverStatus should return network.bytesOut >= 0", + ), + DiagnosticTestCase( + id="network_numRequests_gte_0", + checks={"network.numRequests": Gte(0)}, + msg="serverStatus should return network.numRequests >= 0", + ), + DiagnosticTestCase( + id="catalogStats_collections_gte_0", + checks={"catalogStats.collections": Gte(0)}, + msg="serverStatus should return catalogStats.collections >= 0", + ), + DiagnosticTestCase( + id="catalogStats_capped_gte_0", + checks={"catalogStats.capped": Gte(0)}, + msg="serverStatus should return catalogStats.capped >= 0", + ), + DiagnosticTestCase( + id="catalogStats_views_gte_0", + checks={"catalogStats.views": Gte(0)}, + msg="serverStatus should return catalogStats.views >= 0", + ), +] + +VALUE_BOUND_TESTS = POSITIVE_VALUE_TESTS + COUNTER_TESTS + + +@pytest.mark.parametrize("test", pytest_params(VALUE_BOUND_TESTS)) +def test_serverStatus_value_bounds(collection, test): + """Verifies serverStatus fields have expected value bounds.""" + result = execute_admin_command(collection, {"serverStatus": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +# Property [Cross-Field Consistency]: serverStatus fields are consistent across calls. + + +def test_serverStatus_totalCreated_gte_current(collection): + """Verify serverStatus connections.totalCreated >= connections.current.""" + result = execute_admin_command(collection, {"serverStatus": 1}) + current = result["connections"]["current"] + assertProperties( + result, + {"connections.totalCreated": Gte(current)}, + raw_res=True, + msg="serverStatus should report totalCreated >= current connections", + ) + + +def test_serverStatus_uptimeMillis_consistent_with_uptime(collection): + """Verify serverStatus uptime is consistent with uptimeMillis.""" + result = execute_admin_command(collection, {"serverStatus": 1}) + uptime_from_millis = result["uptimeMillis"] // 1000 + assertProperties( + result, + {"uptime": Gte(uptime_from_millis - 1)}, + raw_res=True, + msg="serverStatus should return uptime >= uptimeMillis / 1000 (within 1s tolerance)", + ) + + +def test_serverStatus_pid_consistent_across_calls(collection): + """Verify serverStatus pid is stable across calls.""" + result1 = execute_admin_command(collection, {"serverStatus": 1}) + result2 = execute_admin_command(collection, {"serverStatus": 1}) + assertProperties( + result2, + {"pid": Eq(result1["pid"])}, + raw_res=True, + msg="serverStatus should return the same pid across calls", + ) + + +def test_serverStatus_host_consistent_across_calls(collection): + """Verify serverStatus host is stable across calls.""" + result1 = execute_admin_command(collection, {"serverStatus": 1}) + result2 = execute_admin_command(collection, {"serverStatus": 1}) + assertProperties( + result2, + {"host": Eq(result1["host"])}, + raw_res=True, + msg="serverStatus should return the same host across calls", + ) + + +def test_serverStatus_version_consistent_across_calls(collection): + """Verify serverStatus version is stable across calls.""" + result1 = execute_admin_command(collection, {"serverStatus": 1}) + result2 = execute_admin_command(collection, {"serverStatus": 1}) + assertProperties( + result2, + {"version": Eq(result1["version"])}, + raw_res=True, + msg="serverStatus should return the same version across calls", + ) + + +def test_serverStatus_cross_database_same_core_fields(collection): + """Verify serverStatus core identity fields match across databases.""" + admin_result = execute_admin_command(collection, {"serverStatus": 1}) + db_result = execute_command(collection, {"serverStatus": 1}) + assertProperties( + db_result, + { + "host": Eq(admin_result["host"]), + "version": Eq(admin_result["version"]), + "process": Eq(admin_result["process"]), + "pid": Eq(admin_result["pid"]), + }, + raw_res=True, + msg="serverStatus should return the same core fields regardless of database", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_error_conditions.py new file mode 100644 index 000000000..4344447d4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_error_conditions.py @@ -0,0 +1,40 @@ +"""Tests for serverStatus command error conditions. + +Validates that invalid usages of serverStatus produce appropriate errors. +serverStatus is highly permissive: it accepts any BSON type as the command +argument value, ignores unrecognized fields, and coerces toggle values to +truthy/falsy. The only error condition is a case-mismatched command name, +which is the generic unknown-command rejection, not serverStatus-specific. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import COMMAND_NOT_FOUND_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +# Property [Case Sensitivity]: serverStatus command name is case-sensitive. +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="case_ServerStatus", + command={"ServerStatus": 1}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="serverStatus should reject case-mismatched command name", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_serverStatus_error_conditions(collection, test): + """Verify serverStatus rejects invalid usages with appropriate error codes.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_field_toggle.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_field_toggle.py new file mode 100644 index 000000000..74b2cab26 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_field_toggle.py @@ -0,0 +1,214 @@ +"""Tests for serverStatus command field toggle behavior. + +Validates section inclusion/exclusion via field toggles, multiple +toggle combinations, and toggle value coercion. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, NotExists + +pytestmark = pytest.mark.admin + + +# Property [Section Exclusion]: serverStatus omits a section when its toggle is set to 0. +EXCLUDE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="exclude_metrics", + command={"serverStatus": 1, "metrics": 0}, + checks={"metrics": NotExists()}, + msg="serverStatus should exclude metrics when set to 0", + ), + DiagnosticTestCase( + id="exclude_locks", + command={"serverStatus": 1, "locks": 0}, + checks={"locks": NotExists()}, + msg="serverStatus should exclude locks when set to 0", + ), + DiagnosticTestCase( + id="exclude_connections", + command={"serverStatus": 1, "connections": 0}, + checks={"connections": NotExists()}, + msg="serverStatus should exclude connections when set to 0", + ), + DiagnosticTestCase( + id="exclude_opcounters", + command={"serverStatus": 1, "opcounters": 0}, + checks={"opcounters": NotExists()}, + msg="serverStatus should exclude opcounters when set to 0", + ), + DiagnosticTestCase( + id="exclude_asserts", + command={"serverStatus": 1, "asserts": 0}, + checks={"asserts": NotExists()}, + msg="serverStatus should exclude asserts when set to 0", + ), + DiagnosticTestCase( + id="exclude_network", + command={"serverStatus": 1, "network": 0}, + checks={"network": NotExists()}, + msg="serverStatus should exclude network when set to 0", + ), + DiagnosticTestCase( + id="exclude_globalLock", + command={"serverStatus": 1, "globalLock": 0}, + checks={"globalLock": NotExists()}, + msg="serverStatus should exclude globalLock when set to 0", + ), + DiagnosticTestCase( + id="exclude_extra_info", + command={"serverStatus": 1, "extra_info": 0}, + checks={"extra_info": NotExists()}, + msg="serverStatus should exclude extra_info when set to 0", + ), + DiagnosticTestCase( + id="exclude_transactions", + command={"serverStatus": 1, "transactions": 0}, + checks={"transactions": NotExists()}, + msg="serverStatus should exclude transactions when set to 0", + ), +] + +# Property [Section Inclusion]: serverStatus includes non-default sections when toggled to 1. +INCLUDE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="include_mirroredReads", + command={"serverStatus": 1, "mirroredReads": 1}, + checks={"mirroredReads": Exists()}, + msg="serverStatus should include mirroredReads when set to 1", + ), +] + +# Property [Multiple Toggles]: serverStatus respects multiple section toggles simultaneously. +MULTIPLE_TOGGLE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="exclude_multiple_sections", + command={"serverStatus": 1, "metrics": 0, "locks": 0, "asserts": 0}, + checks={ + "metrics": NotExists(), + "locks": NotExists(), + "asserts": NotExists(), + "connections": IsType("object"), + "opcounters": IsType("object"), + }, + msg="serverStatus should exclude multiple sections when each is set to 0", + ), + DiagnosticTestCase( + id="exclude_some_include_some", + command={"serverStatus": 1, "metrics": 0, "mirroredReads": 1}, + checks={ + "metrics": NotExists(), + "mirroredReads": Exists(), + }, + msg="serverStatus should respect mixed include and exclude toggles", + ), +] + +# Property [Toggle Value Coercion]: serverStatus coerces toggle values to truthy or falsy. +TOGGLE_COERCION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="toggle_false_excludes", + command={"serverStatus": 1, "metrics": False}, + checks={"metrics": NotExists()}, + msg="serverStatus should exclude section when toggle is boolean false", + ), + DiagnosticTestCase( + id="toggle_true_includes", + command={"serverStatus": 1, "mirroredReads": True}, + checks={"mirroredReads": Exists()}, + msg="serverStatus should include section when toggle is boolean true", + ), + DiagnosticTestCase( + id="toggle_negative_includes", + command={"serverStatus": 1, "mirroredReads": -1}, + checks={"mirroredReads": Exists()}, + msg="serverStatus should include section when toggle is negative int (truthy)", + ), + DiagnosticTestCase( + id="toggle_string_includes", + command={"serverStatus": 1, "mirroredReads": "hello"}, + checks={"mirroredReads": Exists()}, + msg="serverStatus should include section when toggle is string (truthy)", + ), + DiagnosticTestCase( + id="toggle_null_excludes", + command={"serverStatus": 1, "metrics": None}, + checks={"metrics": NotExists()}, + msg="serverStatus should exclude section when toggle is null (falsy)", + ), + DiagnosticTestCase( + id="toggle_object_includes", + command={"serverStatus": 1, "mirroredReads": {}}, + checks={"mirroredReads": Exists()}, + msg="serverStatus should include section when toggle is empty object (truthy)", + ), + DiagnosticTestCase( + id="toggle_array_includes", + command={"serverStatus": 1, "mirroredReads": []}, + checks={"mirroredReads": Exists()}, + msg="serverStatus should include section when toggle is empty array (truthy)", + ), +] + +# Property [Core Fields Preserved]: serverStatus core fields are unaffected by section toggles. +CORE_FIELDS_PRESERVED_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="core_fields_present_with_exclusions", + command={"serverStatus": 1, "metrics": 0, "locks": 0}, + checks={ + "ok": Exists(), + "host": Exists(), + "version": Exists(), + "process": Exists(), + "pid": Exists(), + "uptime": Exists(), + "localTime": Exists(), + }, + msg="serverStatus should include core fields even when optional sections are excluded", + ), +] + +# Property [Default Exclusions]: serverStatus omits certain sections by default. +DEFAULT_EXCLUSION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="mirroredReads_excluded_by_default", + command={"serverStatus": 1}, + checks={"mirroredReads": NotExists()}, + msg="serverStatus should exclude mirroredReads by default", + ), +] + +# Property [Unrecognized Fields]: serverStatus accepts unknown fields as section toggles. +UNRECOGNIZED_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="unrecognized_field_accepted", + command={"serverStatus": 1, "completelyFakeFieldName": 1}, + checks={"ok": Eq(1.0)}, + msg="serverStatus should accept unrecognized fields as section toggles", + ), +] + +TOGGLE_TESTS = ( + EXCLUDE_TESTS + + INCLUDE_TESTS + + MULTIPLE_TOGGLE_TESTS + + TOGGLE_COERCION_TESTS + + CORE_FIELDS_PRESERVED_TESTS + + DEFAULT_EXCLUSION_TESTS + + UNRECOGNIZED_FIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(TOGGLE_TESTS)) +def test_serverStatus_field_toggle(collection, test): + """Verifies serverStatus section toggle behavior.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_response_structure.py new file mode 100644 index 000000000..a6a23afe6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/serverStatus/test_serverStatus_response_structure.py @@ -0,0 +1,311 @@ +"""Tests for serverStatus command response structure. + +Validates presence and types of core response fields, default sections, +and sub-document fields returned by serverStatus. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, NonEmptyStr + +pytestmark = pytest.mark.admin + + +# Property [Top-Level Fields]: serverStatus response contains core fields with expected types. +TOP_LEVEL_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="ok_is_1", + checks={"ok": Eq(1.0)}, + msg="serverStatus should return ok: 1.0", + ), + DiagnosticTestCase( + id="host_is_string", + checks={"host": IsType("string")}, + msg="serverStatus should return host as a string", + ), + DiagnosticTestCase( + id="version_is_string", + checks={"version": IsType("string")}, + msg="serverStatus should return version as a string", + ), + DiagnosticTestCase( + id="process_is_string", + checks={"process": IsType("string")}, + msg="serverStatus should return process as a string", + ), + DiagnosticTestCase( + id="pid_is_long", + checks={"pid": IsType("long")}, + msg="serverStatus should return pid as a long", + ), + DiagnosticTestCase( + id="uptime_is_double", + checks={"uptime": IsType("double")}, + msg="serverStatus should return uptime as a double", + ), + DiagnosticTestCase( + id="uptimeMillis_is_long", + checks={"uptimeMillis": IsType("long")}, + msg="serverStatus should return uptimeMillis as a long", + ), + DiagnosticTestCase( + id="uptimeEstimate_is_long", + checks={"uptimeEstimate": IsType("long")}, + msg="serverStatus should return uptimeEstimate as a long", + ), + DiagnosticTestCase( + id="localTime_is_date", + checks={"localTime": IsType("date")}, + msg="serverStatus should return localTime as a date", + ), +] + +# Property [Default Sections]: serverStatus includes standard sections as objects by default. +DEFAULT_SECTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="asserts_is_object", + checks={"asserts": IsType("object")}, + msg="serverStatus should include asserts as an object", + ), + DiagnosticTestCase( + id="connections_is_object", + checks={"connections": IsType("object")}, + msg="serverStatus should include connections as an object", + ), + DiagnosticTestCase( + id="extra_info_is_object", + checks={"extra_info": IsType("object")}, + msg="serverStatus should include extra_info as an object", + ), + DiagnosticTestCase( + id="globalLock_is_object", + checks={"globalLock": IsType("object")}, + msg="serverStatus should include globalLock as an object", + ), + DiagnosticTestCase( + id="locks_is_object", + checks={"locks": IsType("object")}, + msg="serverStatus should include locks as an object", + ), + DiagnosticTestCase( + id="logicalSessionRecordCache_is_object", + checks={"logicalSessionRecordCache": IsType("object")}, + msg="serverStatus should include logicalSessionRecordCache as an object", + ), + DiagnosticTestCase( + id="mem_is_object", + checks={"mem": IsType("object")}, + msg="serverStatus should include mem as an object", + ), + DiagnosticTestCase( + id="metrics_is_object", + checks={"metrics": IsType("object")}, + msg="serverStatus should include metrics as an object", + ), + DiagnosticTestCase( + id="network_is_object", + checks={"network": IsType("object")}, + msg="serverStatus should include network as an object", + ), + DiagnosticTestCase( + id="opcounters_is_object", + checks={"opcounters": IsType("object")}, + msg="serverStatus should include opcounters as an object", + ), + DiagnosticTestCase( + id="opcountersRepl_is_object", + checks={"opcountersRepl": IsType("object")}, + msg="serverStatus should include opcountersRepl as an object", + ), + DiagnosticTestCase( + id="storageEngine_is_object", + checks={"storageEngine": IsType("object")}, + msg="serverStatus should include storageEngine as an object", + ), + DiagnosticTestCase( + id="transactions_is_object", + checks={"transactions": IsType("object")}, + msg="serverStatus should include transactions as an object", + ), + DiagnosticTestCase( + id="catalogStats_is_object", + checks={"catalogStats": IsType("object")}, + msg="serverStatus should include catalogStats as an object", + ), +] + +# Property [Sub-Document Fields]: serverStatus sections contain expected nested fields. +SUB_DOC_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="asserts_regular_exists", + checks={"asserts.regular": Exists()}, + msg="serverStatus should include asserts.regular", + ), + DiagnosticTestCase( + id="asserts_warning_exists", + checks={"asserts.warning": Exists()}, + msg="serverStatus should include asserts.warning", + ), + DiagnosticTestCase( + id="asserts_msg_exists", + checks={"asserts.msg": Exists()}, + msg="serverStatus should include asserts.msg", + ), + DiagnosticTestCase( + id="asserts_user_exists", + checks={"asserts.user": Exists()}, + msg="serverStatus should include asserts.user", + ), + DiagnosticTestCase( + id="asserts_rollovers_exists", + checks={"asserts.rollovers": Exists()}, + msg="serverStatus should include asserts.rollovers", + ), + DiagnosticTestCase( + id="connections_current_exists", + checks={"connections.current": Exists()}, + msg="serverStatus should include connections.current", + ), + DiagnosticTestCase( + id="connections_available_exists", + checks={"connections.available": Exists()}, + msg="serverStatus should include connections.available", + ), + DiagnosticTestCase( + id="connections_totalCreated_exists", + checks={"connections.totalCreated": Exists()}, + msg="serverStatus should include connections.totalCreated", + ), + DiagnosticTestCase( + id="connections_active_exists", + checks={"connections.active": Exists()}, + msg="serverStatus should include connections.active", + ), + DiagnosticTestCase( + id="opcounters_insert_exists", + checks={"opcounters.insert": Exists()}, + msg="serverStatus should include opcounters.insert", + ), + DiagnosticTestCase( + id="opcounters_query_exists", + checks={"opcounters.query": Exists()}, + msg="serverStatus should include opcounters.query", + ), + DiagnosticTestCase( + id="opcounters_update_exists", + checks={"opcounters.update": Exists()}, + msg="serverStatus should include opcounters.update", + ), + DiagnosticTestCase( + id="opcounters_delete_exists", + checks={"opcounters.delete": Exists()}, + msg="serverStatus should include opcounters.delete", + ), + DiagnosticTestCase( + id="opcounters_getmore_exists", + checks={"opcounters.getmore": Exists()}, + msg="serverStatus should include opcounters.getmore", + ), + DiagnosticTestCase( + id="opcounters_command_exists", + checks={"opcounters.command": Exists()}, + msg="serverStatus should include opcounters.command", + ), + DiagnosticTestCase( + id="network_bytesIn_exists", + checks={"network.bytesIn": Exists()}, + msg="serverStatus should include network.bytesIn", + ), + DiagnosticTestCase( + id="network_bytesOut_exists", + checks={"network.bytesOut": Exists()}, + msg="serverStatus should include network.bytesOut", + ), + DiagnosticTestCase( + id="network_numRequests_exists", + checks={"network.numRequests": Exists()}, + msg="serverStatus should include network.numRequests", + ), + DiagnosticTestCase( + id="globalLock_totalTime_exists", + checks={"globalLock.totalTime": Exists()}, + msg="serverStatus should include globalLock.totalTime", + ), + DiagnosticTestCase( + id="globalLock_currentQueue_is_object", + checks={"globalLock.currentQueue": IsType("object")}, + msg="serverStatus should return globalLock.currentQueue as an object", + ), + DiagnosticTestCase( + id="globalLock_activeClients_is_object", + checks={"globalLock.activeClients": IsType("object")}, + msg="serverStatus should return globalLock.activeClients as an object", + ), + DiagnosticTestCase( + id="storageEngine_name_is_string", + checks={"storageEngine.name": IsType("string")}, + msg="serverStatus should return storageEngine.name as a string", + ), + DiagnosticTestCase( + id="storageEngine_name_is_nonempty", + checks={"storageEngine.name": NonEmptyStr()}, + msg="serverStatus should return a non-empty storageEngine.name", + ), + DiagnosticTestCase( + id="mem_resident_exists", + checks={"mem.resident": Exists()}, + msg="serverStatus should include mem.resident", + ), + DiagnosticTestCase( + id="mem_virtual_exists", + checks={"mem.virtual": Exists()}, + msg="serverStatus should include mem.virtual", + ), + DiagnosticTestCase( + id="catalogStats_collections_exists", + checks={"catalogStats.collections": Exists()}, + msg="serverStatus should include catalogStats.collections", + ), + DiagnosticTestCase( + id="catalogStats_capped_exists", + checks={"catalogStats.capped": Exists()}, + msg="serverStatus should include catalogStats.capped", + ), + DiagnosticTestCase( + id="catalogStats_views_exists", + checks={"catalogStats.views": Exists()}, + msg="serverStatus should include catalogStats.views", + ), + DiagnosticTestCase( + id="catalogStats_timeseries_exists", + checks={"catalogStats.timeseries": Exists()}, + msg="serverStatus should include catalogStats.timeseries", + ), + DiagnosticTestCase( + id="catalogStats_internalCollections_exists", + checks={"catalogStats.internalCollections": Exists()}, + msg="serverStatus should include catalogStats.internalCollections", + ), + DiagnosticTestCase( + id="catalogStats_internalViews_exists", + checks={"catalogStats.internalViews": Exists()}, + msg="serverStatus should include catalogStats.internalViews", + ), +] + +RESPONSE_STRUCTURE_TESTS = TOP_LEVEL_TESTS + DEFAULT_SECTION_TESTS + SUB_DOC_TESTS + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_STRUCTURE_TESTS)) +def test_serverStatus_response_structure(collection, test): + """Verifies serverStatus response fields exist with expected types.""" + result = execute_admin_command(collection, {"serverStatus": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) From b5fb810336acfdd83a0909621f1693ead05e90c8 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:49:13 -0700 Subject: [PATCH 07/51] Add `applyOps` tests (#624) Signed-off-by: Alina (Xi) Li --- .../replication/commands/applyOps/__init__.py | 0 .../test_applyOps_boolean_coercion.py | 83 +++ .../commands/applyOps/test_applyOps_core.py | 497 ++++++++++++++++++ .../test_applyOps_entry_validation.py | 238 +++++++++ .../applyOps/test_applyOps_field_type.py | 86 +++ .../applyOps/test_applyOps_multi_ops.py | 360 +++++++++++++ .../applyOps/test_applyOps_rejected_params.py | 150 ++++++ .../commands/applyOps/test_smoke_applyOps.py | 35 ++ .../system/replication/utils/__init__.py | 0 .../utils/replication_test_case.py | 25 + documentdb_tests/framework/error_codes.py | 4 + documentdb_tests/framework/preconditions.py | 3 + 12 files changed, 1481 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/applyOps/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_boolean_coercion.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_core.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_entry_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_field_type.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_multi_ops.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_rejected_params.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_smoke_applyOps.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/__init__.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_boolean_coercion.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_boolean_coercion.py new file mode 100644 index 000000000..725d8df01 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_boolean_coercion.py @@ -0,0 +1,83 @@ +"""Tests for applyOps boolean coercion of allowAtomic and alwaysUpsert. + +Validates that allowAtomic accepts all BSON types (including null, +negative zero, NaN, Infinity) and that alwaysUpsert: false/null are +accepted (equivalent to default behavior). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(replication=True), pytest.mark.no_parallel] + + +# Property [allowAtomic Accepts All Types]: allowAtomic accepts any value +# without type rejection. All types are silently coerced or ignored. +ALLOWATOMIC_COERCION_SUCCESS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"allowatomic_{tid}", + command=lambda ctx, v=val: {"applyOps": [], "allowAtomic": v}, + expected={"ok": 1.0}, + msg=f"applyOps should accept allowAtomic: {tid}", + ) + for tid, val in [ + ("bool_true", True), + ("bool_false", False), + ("int32_one", 1), + ("int32_zero", 0), + ("int64_one", Int64(1)), + ("int64_zero", Int64(0)), + ("double_one", 1.0), + ("double_zero", 0.0), + ("decimal128_one", Decimal128("1")), + ("decimal128_zero", Decimal128("0")), + ("string", "true"), + ("array", []), + ("object", {}), + ("null", None), + ("neg_zero", -0.0), + ("nan", float("nan")), + ("infinity", float("inf")), + ] +] + +# Property [alwaysUpsert Accepted Values]: alwaysUpsert: false and null are +# accepted (equivalent to default behavior). +ALWAYSUPSERT_ACCEPTED_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "alwaysupsert_bool_false", + command=lambda ctx: {"applyOps": [], "alwaysUpsert": False}, + expected={"ok": 1.0}, + msg="applyOps should accept alwaysUpsert: false (equivalent to default)", + ), + ReplicationTestCase( + "alwaysupsert_null", + command=lambda ctx: {"applyOps": [], "alwaysUpsert": None}, + expected={"ok": 1.0}, + msg="applyOps should accept alwaysUpsert: null (treated as omitted)", + ), +] + +APPLYOPS_COERCION_SUCCESS_TESTS: list[ReplicationTestCase] = ( + ALLOWATOMIC_COERCION_SUCCESS_TESTS + ALWAYSUPSERT_ACCEPTED_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_COERCION_SUCCESS_TESTS)) +def test_applyOps_boolean_coercion(collection, test): + """Test applyOps boolean coercion success cases.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_core.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_core.py new file mode 100644 index 000000000..e7a2ee95b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_core.py @@ -0,0 +1,497 @@ +"""Tests for applyOps command core behavior: operation types, response structure, +and basic functionality.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import CappedCollection, SiblingCollection + +pytestmark = [pytest.mark.requires(replication=True), pytest.mark.no_parallel] + + +# Property [Insert Operations]: applyOps inserts documents into existing +# collections via the "i" op type. +APPLYOPS_INSERT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "insert_single_document", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert a single document", + ), + ReplicationTestCase( + "insert_multiple_documents", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "a": 1}}, + {"op": "i", "ns": ctx.namespace, "o": {"_id": 2, "a": 2}}, + {"op": "i", "ns": ctx.namespace, "o": {"_id": 3, "a": 3}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert multiple documents", + ), + ReplicationTestCase( + "insert_all_bson_types", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + { + "op": "i", + "ns": ctx.namespace, + "o": { + "_id": 1, + "int32": 42, + "int64": Int64(123_456_789), + "double": 3.14, + "decimal128": Decimal128("1.23"), + "string": "hello", + "bool": True, + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + "null": None, + "object": {"nested": "value"}, + "array": [1, 2, 3], + "binary": Binary(b"\x00\x01"), + "objectid": ObjectId(), + "regex": Regex("abc", "i"), + "timestamp": Timestamp(1, 1), + "minkey": MinKey(), + "maxkey": MaxKey(), + }, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert a document with all BSON types", + ), + ReplicationTestCase( + "insert_nested_document", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + { + "op": "i", + "ns": ctx.namespace, + "o": { + "_id": 1, + "a": {"b": {"c": {"d": 1}}}, + "arr": [[1, 2], [3, 4]], + "mixed": {"x": [{"y": 1}, {"y": 2}]}, + }, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert a nested document", + ), + ReplicationTestCase( + "insert_duplicate_id", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 2}}], + }, + expected={"ok": Eq(1.0), "applied": Eq(1)}, + msg="applyOps should succeed on duplicate _id insert", + ), + ReplicationTestCase( + "insert_into_capped_collection", + target_collection=CappedCollection(size=1_048_576), + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert into a capped collection", + ), +] + +# Property [Update Operations]: applyOps updates documents via the "u" op type. +APPLYOPS_UPDATE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "update_existing_document", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 2, "y": 3}, + "o2": {"_id": 1}, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should update an existing document", + ), + ReplicationTestCase( + "update_with_set_modifier", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"$v": 2, "diff": {"u": {"x": 2}}}, + "o2": {"_id": 1}, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should update with $v:2 diff format", + ), +] + +# Property [Delete Operations]: applyOps deletes documents via the "d" op type. +APPLYOPS_DELETE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "delete_existing_document", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [{"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should delete an existing document", + ), + ReplicationTestCase( + "delete_nonexistent_document", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "d", "ns": ctx.namespace, "o": {"_id": 999}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should succeed silently when deleting a non-existent document", + ), +] + +# Property [No-op Operations]: applyOps accepts no-op entries via the "n" op type. +APPLYOPS_NOOP_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "noop_operation", + command=lambda ctx: { + "applyOps": [{"op": "n", "ns": ctx.namespace, "o": {}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should accept no-op operation", + ), + ReplicationTestCase( + "noop_with_arbitrary_o", + command=lambda ctx: { + "applyOps": [ + {"op": "n", "ns": ctx.namespace, "o": {"msg": "test message"}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should accept no-op with arbitrary o document", + ), +] + +# Property [Command Operations]: applyOps executes DDL commands via the "c" op type. +APPLYOPS_COMMAND_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "command_create_collection", + docs=[], + command=lambda ctx: { + "applyOps": [ + { + "op": "c", + "ns": f"{ctx.database}.$cmd", + "o": {"create": f"{ctx.collection}_applyops_create"}, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should create a collection via command operation", + ), + ReplicationTestCase( + "command_drop_collection", + docs=[], + siblings=[SiblingCollection(suffix="_applyops_drop")], + command=lambda ctx: { + "applyOps": [ + { + "op": "c", + "ns": f"{ctx.database}.$cmd", + "o": {"drop": f"{ctx.collection}_applyops_drop"}, + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should drop a collection via command operation", + ), +] + +# Property [Empty Array]: applyOps succeeds with an empty operations array. +APPLYOPS_EMPTY_ARRAY_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "empty_ops_array", + command=lambda ctx: {"applyOps": []}, + expected={"ok": Eq(1.0)}, + msg="applyOps should succeed with an empty operations array", + ), +] + +# Property [Unrecognized Field Acceptance]: unknown fields are silently ignored. +APPLYOPS_UNRECOGNIZED_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "unrecognized_single_field", + command=lambda ctx: {"applyOps": [], "unknownField": 1}, + expected={"ok": Eq(1.0)}, + msg="applyOps should ignore unrecognized fields", + ), + ReplicationTestCase( + "unrecognized_multiple_fields", + command=lambda ctx: {"applyOps": [], "foo": 1, "bar": "baz"}, + expected={"ok": Eq(1.0)}, + msg="applyOps should ignore multiple unrecognized fields", + ), + ReplicationTestCase( + "unrecognized_dollar_prefix", + command=lambda ctx: {"applyOps": [], "$unknown": 1}, + expected={"ok": Eq(1.0)}, + msg="applyOps should ignore dollar-prefixed unrecognized fields", + ), +] + +# Property [Response Structure]: applyOps returns ok, applied, and results +# fields reflecting the operations performed. +APPLYOPS_RESPONSE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "response_empty_ops", + command=lambda ctx: {"applyOps": []}, + expected={"ok": Eq(1.0), "applied": Eq(0), "results": Eq([])}, + msg="applyOps should return applied: 0 and results: [] for empty ops", + ), + ReplicationTestCase( + "response_single_op", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + }, + expected={"ok": Eq(1.0), "applied": Eq(1), "results": Eq([True])}, + msg="applyOps should return applied: 1 and results: [True] for single insert", + ), + ReplicationTestCase( + "response_multiple_ops", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "a": 1}}, + {"op": "i", "ns": ctx.namespace, "o": {"_id": 2, "a": 2}}, + ], + }, + expected={ + "ok": Eq(1.0), + "applied": Eq(2), + "results": Eq([True, True]), + }, + msg="applyOps should return applied: 2 and results: [True, True] for two inserts", + ), + ReplicationTestCase( + "response_noop_op", + command=lambda ctx: { + "applyOps": [{"op": "n", "ns": ctx.namespace, "o": {}}], + }, + expected={"ok": Eq(1.0), "applied": Eq(0)}, + msg="applyOps should not count no-op in applied", + ), +] + +APPLYOPS_CORE_TESTS: list[ReplicationTestCase] = ( + APPLYOPS_INSERT_TESTS + + APPLYOPS_UPDATE_TESTS + + APPLYOPS_DELETE_TESTS + + APPLYOPS_NOOP_TESTS + + APPLYOPS_COMMAND_TESTS + + APPLYOPS_EMPTY_ARRAY_TESTS + + APPLYOPS_UNRECOGNIZED_FIELD_TESTS + + APPLYOPS_RESPONSE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_CORE_TESTS)) +def test_applyOps_core(database_client, collection, test): + """Test applyOps command core behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Idempotent Operations]: applying the same operation twice succeeds. +APPLYOPS_IDEMPOTENT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "idempotent_delete", + docs=[{"_id": 1, "x": 1}], + setup=lambda coll: execute_admin_command( + coll, + {"applyOps": [{"op": "d", "ns": f"{coll.database.name}.{coll.name}", "o": {"_id": 1}}]}, + ), + command=lambda ctx: { + "applyOps": [{"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should succeed silently when deleting an already-deleted document", + ), + ReplicationTestCase( + "idempotent_insert", + docs=[{"_id": 0, "setup": True}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 1}, + } + ] + }, + ), + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 2}}], + }, + expected={"ok": Eq(1.0), "applied": Eq(1), "results": Eq([True])}, + msg="applyOps should succeed on duplicate _id insert (second apply)", + ), + ReplicationTestCase( + "idempotent_noop", + docs=[], + setup=lambda coll: execute_admin_command( + coll, + {"applyOps": [{"op": "n", "ns": f"{coll.database.name}.{coll.name}", "o": {}}]}, + ), + command=lambda ctx: { + "applyOps": [{"op": "n", "ns": ctx.namespace, "o": {}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should succeed when applying no-op twice", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_IDEMPOTENT_TESTS)) +def test_applyOps_idempotent(database_client, collection, test): + """Test applyOps idempotent operations.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Document Effect Verification]: applyOps operations produce the +# expected documents in the collection. +APPLYOPS_DOC_CHECK_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "doc_check_insert", + docs=[{"_id": 0, "setup": True}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 1}, + } + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[{"_id": 1, "x": 1}], + msg="applyOps insert should create the document", + ), + ReplicationTestCase( + "doc_check_update", + docs=[{"_id": 1, "x": 1}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "u", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 2, "y": 3}, + "o2": {"_id": 1}, + } + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[{"_id": 1, "x": 2, "y": 3}], + msg="applyOps update should modify the document", + ), + ReplicationTestCase( + "doc_check_delete", + docs=[{"_id": 1, "x": 1}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "d", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1}, + } + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[], + msg="applyOps delete should remove the document", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_DOC_CHECK_TESTS)) +def test_applyOps_doc_check(database_client, collection, test): + """Test applyOps operations produce the expected documents.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_entry_validation.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_entry_validation.py new file mode 100644 index 000000000..180410dca --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_entry_validation.py @@ -0,0 +1,238 @@ +"""Tests for applyOps operation entry validation.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + ILLEGAL_OPERATION_ERROR, + MISSING_FIELD_ERROR, + NAMESPACE_NOT_FOUND_ERROR, + NO_SUCH_KEY_ERROR, + TYPE_MISMATCH_ERROR, + UNKNOWN_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(replication=True), pytest.mark.no_parallel] + + +# Property [Missing Required Fields]: applyOps rejects entries that omit +# required fields (op, ns, o). +APPLYOPS_MISSING_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "missing_op", + command=lambda ctx: { + "applyOps": [{"ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=NO_SUCH_KEY_ERROR, + msg="applyOps should reject entry without op field", + ), + ReplicationTestCase( + "missing_ns", + command=lambda ctx: {"applyOps": [{"op": "i", "o": {"_id": 1}}]}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject entry without ns field", + ), + ReplicationTestCase( + "missing_o", + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace}], + }, + error_code=MISSING_FIELD_ERROR, + msg="applyOps should reject entry without o field", + ), + ReplicationTestCase( + "insert_empty_document_missing_id", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {}}], + }, + error_code=NO_SUCH_KEY_ERROR, + msg="applyOps should reject insert of document without _id", + ), +] + + +# Property [Invalid Op Type]: applyOps rejects entries with invalid op values. +APPLYOPS_INVALID_OP_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "op_invalid_char", + command=lambda ctx: { + "applyOps": [{"op": "x", "ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=BAD_VALUE_ERROR, + msg="applyOps should reject invalid op type", + ), + ReplicationTestCase( + "op_empty_string", + command=lambda ctx: { + "applyOps": [{"op": "", "ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject empty string op type", + ), + ReplicationTestCase( + "op_non_string", + command=lambda ctx: { + "applyOps": [{"op": 123, "ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject non-string op type", + ), + ReplicationTestCase( + "op_null", + command=lambda ctx: { + "applyOps": [{"op": None, "ns": ctx.namespace, "o": {"_id": 1}}], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject null op type", + ), +] + + +# Property [Invalid Namespace]: applyOps rejects entries with invalid ns values. +APPLYOPS_INVALID_NS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "ns_empty_string", + command=lambda ctx: {"applyOps": [{"op": "i", "ns": "", "o": {"_id": 1}}]}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject empty string namespace", + ), + ReplicationTestCase( + "ns_non_string", + command=lambda ctx: {"applyOps": [{"op": "i", "ns": 123, "o": {"_id": 1}}]}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject non-string namespace", + ), + ReplicationTestCase( + "ns_null", + command=lambda ctx: {"applyOps": [{"op": "i", "ns": None, "o": {"_id": 1}}]}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="applyOps should reject null namespace", + ), +] + + +# Property [Invalid Array Entries]: applyOps rejects non-object entries in +# the operations array. +APPLYOPS_INVALID_ENTRY_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "entry_non_object_int", + command=lambda ctx: {"applyOps": [1, 2, 3]}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject integer entries", + ), + ReplicationTestCase( + "entry_non_object_string", + command=lambda ctx: {"applyOps": ["insert"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject string entries", + ), + ReplicationTestCase( + "entry_null", + command=lambda ctx: {"applyOps": [None]}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject null entry", + ), + ReplicationTestCase( + "entry_empty_object", + command=lambda ctx: {"applyOps": [{}]}, + error_code=NO_SUCH_KEY_ERROR, + msg="applyOps should reject empty object entry", + ), +] + + +# Property [Nonexistent Collection]: applyOps rejects insert/update operations +# targeting a namespace that does not exist. +APPLYOPS_NONEXISTENT_NS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "insert_nonexistent_collection", + docs=[], + command=lambda ctx: { + "applyOps": [ + { + "op": "i", + "ns": f"{ctx.database}.{ctx.collection}_nonexistent", + "o": {"_id": 1, "x": 1}, + } + ], + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="applyOps should reject insert into non-existent collection", + ), +] + +# Property [o2 Field Validation]: o2 (update query document) must contain +# _id and must match an existing document. +APPLYOPS_O2_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "update_o2_unmatched_id", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 999, "x": 2}, + "o2": {"_id": 999}, + } + ], + }, + error_code=UNKNOWN_ERROR, + msg="applyOps should reject update when o2._id does not match any document", + ), + ReplicationTestCase( + "update_o2_missing_id", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 99}, + "o2": {}, + } + ], + }, + error_code=NO_SUCH_KEY_ERROR, + msg="applyOps should reject update when o2 is missing _id", + ), +] + +APPLYOPS_ENTRY_VALIDATION_TESTS: list[ReplicationTestCase] = ( + APPLYOPS_MISSING_FIELD_TESTS + + APPLYOPS_INVALID_OP_TESTS + + APPLYOPS_INVALID_NS_TESTS + + APPLYOPS_INVALID_ENTRY_TESTS + + APPLYOPS_NONEXISTENT_NS_TESTS + + APPLYOPS_O2_FIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_ENTRY_VALIDATION_TESTS)) +def test_applyOps_entry_validation(database_client, collection, test): + """Test applyOps operation entry validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_field_type.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_field_type.py new file mode 100644 index 000000000..11b2e1e66 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_field_type.py @@ -0,0 +1,86 @@ +"""Tests for applyOps command field type rejection.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(replication=True), pytest.mark.no_parallel] + + +# Property [Command Field Type Rejection]: the applyOps command field expects +# an array. All non-array BSON types are rejected. +APPLYOPS_FIELD_TYPE_ERROR_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"field_type_{tid}", + command=lambda ctx, v=val: {"applyOps": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"applyOps should reject {tid} as command field value", + ) + for tid, val in [ + ("int32_positive", 1), + ("int32_zero", 0), + ("int32_negative", -1), + ("int64", Int64(1)), + ("int64_max", Int64(9_223_372_036_854_775_807)), + ("double", 1.0), + ("double_zero", 0.0), + ("double_negative", -1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("nan", float("nan")), + ("infinity", float("inf")), + ("string", "test"), + ("string_empty", ""), + ("object_empty", {}), + ("object", {"key": "value"}), + ("binary", Binary(b"\x00\x01\x02")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*", "i")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + [ + # Null also produces a type mismatch error. + ReplicationTestCase( + "field_type_null", + command=lambda ctx: {"applyOps": None}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject null as command field value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_FIELD_TYPE_ERROR_TESTS)) +def test_applyOps_field_type(collection, test): + """Test applyOps command field type rejection.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_multi_ops.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_multi_ops.py new file mode 100644 index 000000000..c23a34089 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_multi_ops.py @@ -0,0 +1,360 @@ +"""Tests for applyOps multi-operation interactions and optional parameters. + +Validates multi-op batches, cross-namespace operations, and +allowAtomic behavior. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import SiblingCollection + +pytestmark = [pytest.mark.requires(replication=True), pytest.mark.no_parallel] + + +# Property [Multi-Operation Batches]: applyOps applies multiple operations +# in a single batch, combining insert, update, delete, and no-op ops. +APPLYOPS_MULTI_OPS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "insert_then_update", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}, + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 2}, + "o2": {"_id": 1}, + }, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert then update in one batch", + ), + ReplicationTestCase( + "insert_then_delete", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}, + {"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert then delete in one batch", + ), + ReplicationTestCase( + "update_then_delete", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 2}, + "o2": {"_id": 1}, + }, + {"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should update then delete in one batch", + ), + ReplicationTestCase( + "multiple_inserts", + docs=[{"_id": -1, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": i, "x": i}} for i in range(5) + ], + }, + expected={"ok": Eq(1.0), "applied": Eq(5)}, + msg="applyOps should insert 5 documents", + ), + ReplicationTestCase( + "mixed_op_types", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}, + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 2}, + "o2": {"_id": 1}, + }, + {"op": "n", "ns": ctx.namespace, "o": {}}, + {"op": "d", "ns": ctx.namespace, "o": {"_id": 1}}, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should handle mixed operation types", + ), + ReplicationTestCase( + "cross_namespace", + docs=[], + siblings=[SiblingCollection(suffix="_ns1"), SiblingCollection(suffix="_ns2")], + command=lambda ctx: { + "applyOps": [ + { + "op": "i", + "ns": f"{ctx.database}.{ctx.collection}_ns1", + "o": {"_id": 1, "src": "coll1"}, + }, + { + "op": "i", + "ns": f"{ctx.database}.{ctx.collection}_ns2", + "o": {"_id": 1, "src": "coll2"}, + }, + ], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should insert into multiple namespaces", + ), +] + +# Property [allowAtomic]: applyOps accepts the allowAtomic option with +# boolean values and defaults to true when omitted. +APPLYOPS_ALLOW_ATOMIC_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "allow_atomic_true", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + "allowAtomic": True, + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should accept allowAtomic: true", + ), + ReplicationTestCase( + "allow_atomic_false", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + "allowAtomic": False, + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should accept allowAtomic: false", + ), + ReplicationTestCase( + "allow_atomic_omitted", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1, "x": 1}}], + }, + expected={"ok": Eq(1.0)}, + msg="applyOps should default allowAtomic to true", + ), +] + +# Property [allowAtomic Effectiveness]: allowAtomic: false allows partial +# commits when a later operation fails. +APPLYOPS_ATOMIC_EFFECTIVENESS_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "atomic_false_partial_commit", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "applyOps": [ + {"op": "i", "ns": ctx.namespace, "o": {"_id": 2, "x": 2}}, + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 999, "x": 3}, + "o2": {"_id": 999}, + }, + ], + "allowAtomic": False, + }, + error_code=UNKNOWN_ERROR, + msg="applyOps with allowAtomic: false reports error when second op fails", + ), +] + +APPLYOPS_MULTI_OPS_AND_OPTIONS_TESTS: list[ReplicationTestCase] = ( + APPLYOPS_MULTI_OPS_TESTS + APPLYOPS_ALLOW_ATOMIC_TESTS + APPLYOPS_ATOMIC_EFFECTIVENESS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_MULTI_OPS_AND_OPTIONS_TESTS)) +def test_applyOps_multi_ops(database_client, collection, test): + """Test applyOps multi-operation interactions and optional parameters.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Multi-Op Document Effect Verification]: multi-operation batches +# and allowAtomic produce the expected documents in the collection. +APPLYOPS_MULTI_OPS_DOC_CHECK_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "doc_check_insert_then_update", + docs=[{"_id": 0, "setup": True}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 1}, + }, + { + "op": "u", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 2}, + "o2": {"_id": 1}, + }, + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[{"_id": 1, "x": 2}], + msg="applyOps should leave the updated document after insert-then-update", + ), + ReplicationTestCase( + "doc_check_insert_then_delete", + docs=[{"_id": 0, "setup": True}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1, "x": 1}, + }, + { + "op": "d", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 1}, + }, + ] + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 1}}, + use_admin=False, + expected=[], + msg="applyOps should leave no document after insert-then-delete", + ), + ReplicationTestCase( + "doc_check_atomic_false_partial_commit", + docs=[{"_id": 1, "x": 1}], + setup=lambda coll: execute_admin_command( + coll, + { + "applyOps": [ + { + "op": "i", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 2, "x": 2}, + }, + { + "op": "u", + "ns": f"{coll.database.name}.{coll.name}", + "o": {"_id": 999, "x": 3}, + "o2": {"_id": 999}, + }, + ], + "allowAtomic": False, + }, + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": 2}}, + use_admin=False, + expected=[{"_id": 2, "x": 2}], + msg="applyOps with allowAtomic: false should have committed the first insert", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_MULTI_OPS_DOC_CHECK_TESTS)) +def test_applyOps_multi_ops_doc_check(database_client, collection, test): + """Test applyOps multi-operation document effects.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + ) + + +def test_applyOps_cross_namespace_doc_check_ns1(database_client, collection): + """Test applyOps cross-namespace insert creates document in first collection.""" + db = database_client + coll_name = collection.name + ns1 = f"{db.name}.{coll_name}_ns1" + ns2 = f"{db.name}.{coll_name}_ns2" + db.create_collection(f"{coll_name}_ns1") + db.create_collection(f"{coll_name}_ns2") + execute_admin_command( + collection, + { + "applyOps": [ + {"op": "i", "ns": ns1, "o": {"_id": 1, "src": "coll1"}}, + {"op": "i", "ns": ns2, "o": {"_id": 1, "src": "coll2"}}, + ] + }, + ) + coll1 = db[f"{coll_name}_ns1"] + result = execute_command(coll1, {"find": coll1.name, "filter": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 1, "src": "coll1"}], + msg="applyOps should create document in first namespace", + ) + + +def test_applyOps_cross_namespace_doc_check_ns2(database_client, collection): + """Test applyOps cross-namespace insert creates document in second collection.""" + db = database_client + coll_name = collection.name + ns1 = f"{db.name}.{coll_name}_ns1" + ns2 = f"{db.name}.{coll_name}_ns2" + db.create_collection(f"{coll_name}_ns1") + db.create_collection(f"{coll_name}_ns2") + execute_admin_command( + collection, + { + "applyOps": [ + {"op": "i", "ns": ns1, "o": {"_id": 1, "src": "coll1"}}, + {"op": "i", "ns": ns2, "o": {"_id": 1, "src": "coll2"}}, + ] + }, + ) + coll2 = db[f"{coll_name}_ns2"] + result = execute_command(coll2, {"find": coll2.name, "filter": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 1, "src": "coll2"}], + msg="applyOps should create document in second namespace", + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_rejected_params.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_rejected_params.py new file mode 100644 index 000000000..26aa56021 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_applyOps_rejected_params.py @@ -0,0 +1,150 @@ +"""Tests for applyOps rejected and unsupported parameters. + +Validates that applyOps rejects prepare, partialTxn, count, +alwaysUpsert, and preCondition parameters. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + APPLYOPS_ALWAYS_UPSERT_NOT_SUPPORTED_ERROR, + APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR, + BAD_VALUE_ERROR, + PARTIAL_TRANSACTION_NOT_ALLOWED_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(replication=True), pytest.mark.no_parallel] + + +# Property [Rejected Parameters]: prepare, partialTxn, and count fields are +# explicitly rejected by the applyOps command. +APPLYOPS_REJECTED_PARAM_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "rejected_prepare_true", + command=lambda ctx: {"applyOps": [], "prepare": True}, + error_code=BAD_VALUE_ERROR, + msg="applyOps should reject prepare: true", + ), + ReplicationTestCase( + "rejected_prepare_false", + command=lambda ctx: {"applyOps": [], "prepare": False}, + error_code=BAD_VALUE_ERROR, + msg="applyOps should reject prepare: false", + ), + ReplicationTestCase( + "rejected_partial_txn_true", + command=lambda ctx: {"applyOps": [], "partialTxn": True}, + error_code=PARTIAL_TRANSACTION_NOT_ALLOWED_ERROR, + msg="applyOps should reject partialTxn: true", + ), + ReplicationTestCase( + "rejected_partial_txn_false", + command=lambda ctx: {"applyOps": [], "partialTxn": False}, + error_code=PARTIAL_TRANSACTION_NOT_ALLOWED_ERROR, + msg="applyOps should reject partialTxn: false", + ), + ReplicationTestCase( + "rejected_count_true", + command=lambda ctx: {"applyOps": [], "count": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject count: true", + ), + ReplicationTestCase( + "rejected_count_false", + command=lambda ctx: {"applyOps": [], "count": False}, + error_code=TYPE_MISMATCH_ERROR, + msg="applyOps should reject count: false", + ), +] + +# Property [alwaysUpsert Rejection]: alwaysUpsert is no longer supported. +# Boolean true triggers a specific error; non-bool types trigger TYPE_MISMATCH_ERROR. +APPLYOPS_ALWAYSUPSERT_ERROR_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "alwaysupsert_bool_true", + command=lambda ctx: {"applyOps": [], "alwaysUpsert": True}, + error_code=APPLYOPS_ALWAYS_UPSERT_NOT_SUPPORTED_ERROR, + msg="applyOps should reject alwaysUpsert: true (no longer supported)", + ), +] + [ + ReplicationTestCase( + f"alwaysupsert_{tid}", + command=lambda ctx, v=val: {"applyOps": [], "alwaysUpsert": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"applyOps should reject alwaysUpsert: {tid} (wrong type)", + ) + for tid, val in [ + ("int32_one", 1), + ("int32_zero", 0), + ("int64_one", Int64(1)), + ("int64_zero", Int64(0)), + ("double_one", 1.0), + ("double_zero", 0.0), + ("decimal128_one", Decimal128("1")), + ("decimal128_zero", Decimal128("0")), + ("string", "true"), + ("array", []), + ("object", {}), + ] +] + +# Property [preCondition Rejection]: applyOps rejects the preCondition option. +APPLYOPS_PRECONDITION_ERROR_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "precondition_rejected", + docs=[{"_id": 0, "setup": True}], + command=lambda ctx: { + "applyOps": [{"op": "i", "ns": ctx.namespace, "o": {"_id": 1}}], + "preCondition": [], + }, + error_code=APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR, + msg="applyOps should reject preCondition (no longer supported)", + ), + ReplicationTestCase( + "precondition_with_entries_rejected", + docs=[{"_id": 1, "x": 10}], + command=lambda ctx: { + "applyOps": [ + { + "op": "u", + "ns": ctx.namespace, + "o": {"_id": 1, "x": 20}, + "o2": {"_id": 1}, + } + ], + "preCondition": [ + {"ns": ctx.namespace, "q": {"_id": 1}, "res": {"x": 10}}, + ], + }, + error_code=APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR, + msg="applyOps should reject preCondition with entries (no longer supported)", + ), +] + +APPLYOPS_ALL_REJECTED_TESTS: list[ReplicationTestCase] = ( + APPLYOPS_REJECTED_PARAM_TESTS + + APPLYOPS_ALWAYSUPSERT_ERROR_TESTS + + APPLYOPS_PRECONDITION_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(APPLYOPS_ALL_REJECTED_TESTS)) +def test_applyOps_rejected_params(database_client, collection, test): + """Test applyOps rejects unsupported parameters.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_smoke_applyOps.py b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_smoke_applyOps.py new file mode 100644 index 000000000..eee3d96d6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/applyOps/test_smoke_applyOps.py @@ -0,0 +1,35 @@ +""" +Smoke test for applyOps command. + +Tests basic applyOps command functionality by applying a single insert +oplog entry. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = [pytest.mark.smoke, pytest.mark.requires(replication=True), pytest.mark.no_parallel] + + +def test_smoke_applyOps(collection): + """Test basic applyOps command behavior.""" + collection.insert_one({"_id": 0, "setup": True}) + + namespace = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "applyOps": [ + { + "op": "i", + "ns": namespace, + "o": {"_id": 1, "x": 1}, + } + ], + }, + ) + + expected = {"ok": 1.0} + assertSuccessPartial(result, expected, msg="Should support applyOps command") diff --git a/documentdb_tests/compatibility/tests/system/replication/utils/__init__.py b/documentdb_tests/compatibility/tests/system/replication/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py b/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py new file mode 100644 index 000000000..5b93302e2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py @@ -0,0 +1,25 @@ +"""Shared test case for replication command tests.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) + + +@dataclass(frozen=True) +class ReplicationTestCase(CommandTestCase): + """Test case for replication command tests. + + Extends CommandTestCase with a ``use_admin`` flag that controls + whether the command is executed against the admin database. + + Attributes: + use_admin: If True (the default), execute against the admin + database via ``execute_admin_command``. If False, execute + against the test database via ``execute_command``. + """ + + use_admin: bool = True diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 090249637..3b171aa31 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -6,6 +6,7 @@ BAD_VALUE_ERROR = 2 NO_SUCH_KEY_ERROR = 4 GRAPH_CONTAINS_CYCLE_ERROR = 5 +UNKNOWN_ERROR = 8 FAILED_TO_PARSE_ERROR = 9 UNSUPPORTED_FORMAT_ERROR = 12 UNAUTHORIZED_ERROR = 13 @@ -200,6 +201,7 @@ REGEX_MISSING_REGEX_ERROR = 31023 REGEX_UNKNOWN_FIELD_ERROR = 31024 KEY_FIELD_NULL_BYTE_ERROR = 31032 +PARTIAL_TRANSACTION_NOT_ALLOWED_ERROR = 31056 OUT_OF_RANGE_CONVERSION_ERROR = 31109 UNSET_EMPTY_ARRAY_ERROR = 31119 UNSET_ARRAY_ELEMENT_TYPE_ERROR = 31120 @@ -504,6 +506,8 @@ ENCRYPTED_FIELD_UNSUPPORTED_TYPE_ERROR = 6338406 ENCRYPTED_FIELD_VIEW_TIMESERIES_ERROR = 6346401 ENCRYPTED_FIELD_CAPPED_ERROR = 6367301 +APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR = 6711600 +APPLYOPS_ALWAYS_UPSERT_NOT_SUPPORTED_ERROR = 6711601 ENCRYPTED_FIELD_RANGE_MIN_MAX_ERROR = 6720005 ENCRYPTED_FIELD_RANGE_TYPE_ERROR = 6775201 ENCRYPTED_FIELD_RANGE_MIN_MAX_TYPE_ERROR = 7018200 diff --git a/documentdb_tests/framework/preconditions.py b/documentdb_tests/framework/preconditions.py index c090c95b4..8c3bf2534 100644 --- a/documentdb_tests/framework/preconditions.py +++ b/documentdb_tests/framework/preconditions.py @@ -56,6 +56,7 @@ "unforced_compact": "compact succeeds without force", "reindex": "reIndex is permitted", "local_rename": "renaming into the unreplicated local database is permitted", + "replication": "replication commands are available (applyOps, oplog access)", } # The capabilities each (engine, topology) target has. To add an engine or @@ -70,6 +71,7 @@ "cluster_time", "cluster_read_concern", "quorum_write_concern", + "replication", } ), ("mongodb", "standalone"): frozenset( @@ -90,6 +92,7 @@ "quorum_write_concern", "unforced_compact", "reindex", + "replication", } ), } From 10fd3d855e8feb0c8a6c79a2375bd8abc84ab3a6 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 24 Jun 2026 18:06:54 -0700 Subject: [PATCH 08/51] Add replaceRoot aggregation stage tests (#572) Signed-off-by: Daniel Frankcom --- .../replaceRoot/test_replaceRoot_limits.py | 153 +++++++++ .../test_replaceRoot_non_object_errors.py | 186 +++++++++++ .../test_replaceRoot_parse_errors.py | 177 ++++++++++ .../test_replaceRoot_preservation.py | 181 +++++++++++ .../test_replaceRoot_replacement_semantics.py | 306 ++++++++++++++++++ .../stages/test_stages_position_lookup.py | 31 ++ .../test_stages_position_replaceRoot.py | 186 +++++++++++ .../operator/stages/utils/shared_limits.py | 7 + documentdb_tests/framework/error_codes.py | 1 + 9 files changed, 1228 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_limits.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_non_object_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_parse_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_preservation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_replacement_semantics.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_replaceRoot.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_limits.py b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_limits.py new file mode 100644 index 000000000..78b574dbc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_limits.py @@ -0,0 +1,153 @@ +"""Tests for the $replaceRoot aggregation stage.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.shared_limits import ( + BIG_STORED_STRING_BYTES, + BOUNDARY_PAD_BYTES, + MAX_COMMAND_NESTING_DEPTH, + MAX_OUTPUT_DOC_SIZE, + MAX_STORED_NESTING_DEPTH, + OVER_LIMIT_PAD_BYTES, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BSON_OBJECT_TOO_LARGE_ERROR, + OVERFLOW_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + + +def _nest(depth: int, leaf: Any) -> Any: + """Build a value nested ``depth`` single-key objects deep around ``leaf``.""" + value: Any = leaf + for _ in range(depth): + value = {"a": value} + return value + + +# The maximum object nesting depth accepted in a $replaceRoot newRoot literal. +# The aggregate command nests the literal three levels below the command root +# (the pipeline array, the {"$replaceRoot": ...} stage object, and the +# {"newRoot": } specification object), so the accepted depth is the +# global command ceiling minus those three wrapper levels. +REPLACEROOT_MAX_NESTING_DEPTH = MAX_COMMAND_NESTING_DEPTH - 3 + + +# Property [Deeply Nested Promotion Accepted]: a sub-document stored at the +# maximum nesting depth can be promoted to the top level. +REPLACEROOT_DEEP_PROMOTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "deep_promotion_max_stored_depth", + docs=[{"_id": 1, "data": _nest(depth=MAX_STORED_NESTING_DEPTH, leaf=1)}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[_nest(depth=MAX_STORED_NESTING_DEPTH, leaf=1)], + msg="$replaceRoot should accept promoting a sub-document stored at the maximum depth", + ), +] + +# Property [Size Boundary Accepted]: a constructed replacement document is +# accepted at the maximum output document size, enforced on the output even when +# the input is within the standard limit. +REPLACEROOT_SIZE_BOUNDARY_TESTS: list[StageTestCase] = [ + StageTestCase( + "size_boundary_max_output_accepted", + docs=[{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}], + pipeline=[ + { + "$replaceRoot": { + "newRoot": {"$mergeObjects": ["$data", {"pad": "y" * BOUNDARY_PAD_BYTES}]} + } + }, + {"$project": {"_id": 0, "size": {"$bsonSize": "$$ROOT"}}}, + ], + expected=[{"size": MAX_OUTPUT_DOC_SIZE}], + msg="$replaceRoot should accept a constructed output document at the maximum size", + ), +] + +# Property [Nesting Boundary Accepted]: a constructed replacement-document +# literal is accepted up to its maximum nesting depth. +REPLACEROOT_NESTING_BOUNDARY_TESTS: list[StageTestCase] = [ + StageTestCase( + "nesting_boundary_max_depth_accepted", + docs=[{"_id": 1}], + pipeline=[ + {"$replaceRoot": {"newRoot": _nest(depth=REPLACEROOT_MAX_NESTING_DEPTH, leaf=1)}} + ], + expected=[_nest(depth=REPLACEROOT_MAX_NESTING_DEPTH, leaf=1)], + msg="$replaceRoot should accept a constructed literal at the maximum nesting depth", + ), +] + +# Property [Size Limit Error]: a constructed output document one byte over the +# maximum output size produces a BSON-object-too-large error, enforced on the +# output document even when the input document is within the standard limit. +REPLACEROOT_SIZE_LIMIT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "size_limit_one_over_rejected", + docs=[{"_id": 1, "data": {"s": "x" * BIG_STORED_STRING_BYTES}}], + pipeline=[ + { + "$replaceRoot": { + "newRoot": {"$mergeObjects": ["$data", {"pad": "y" * OVER_LIMIT_PAD_BYTES}]} + } + } + ], + error_code=BSON_OBJECT_TOO_LARGE_ERROR, + msg="$replaceRoot should reject an output document one byte over the maximum size", + ), +] + +# Property [Nesting Limit Error]: a constructed replacement-document literal one +# level deeper than the maximum nesting depth produces an overflow error. +REPLACEROOT_NESTING_LIMIT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "nesting_limit_one_over_rejected", + docs=[{"_id": 1}], + pipeline=[ + {"$replaceRoot": {"newRoot": _nest(depth=REPLACEROOT_MAX_NESTING_DEPTH + 1, leaf=1)}} + ], + error_code=OVERFLOW_ERROR, + msg="$replaceRoot should reject a literal one level over the maximum nesting depth", + ), +] + +REPLACEROOT_LIMITS_TESTS: list[StageTestCase] = ( + REPLACEROOT_DEEP_PROMOTION_TESTS + + REPLACEROOT_SIZE_BOUNDARY_TESTS + + REPLACEROOT_NESTING_BOUNDARY_TESTS + + REPLACEROOT_SIZE_LIMIT_ERROR_TESTS + + REPLACEROOT_NESTING_LIMIT_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REPLACEROOT_LIMITS_TESTS)) +def test_replaceRoot_limits_cases(collection, test_case: StageTestCase): + """Test $replaceRoot limits cases.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_non_object_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_non_object_errors.py new file mode 100644 index 000000000..7657f3abd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_non_object_errors.py @@ -0,0 +1,186 @@ +"""Tests for the $replaceRoot aggregation stage.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + REPLACE_ROOT_NON_OBJECT_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Type Strictness]: any newRoot resolving to a non-object, non-null +# BSON type produces an error with no coercion to a document. +REPLACEROOT_TYPE_STRICTNESS_TESTS: list[StageTestCase] = [ + StageTestCase( + f"type_{tid}", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": val}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg=f"$replaceRoot should reject a {tid} newRoot with no coercion", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 7), + ("int64", Int64(9_999_999_999)), + ("double", 3.5), + ("decimal128", Decimal128("123.456")), + ("bool_true", True), + ("bool_false", False), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1_700_000_000, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("^abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Arrival-Path Independence]: a non-object newRoot is rejected +# regardless of how the value arrives, with no coercion route. +REPLACEROOT_ARRIVAL_PATH_TESTS: list[StageTestCase] = [ + StageTestCase( + "arrival_literal_scalar", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"$literal": "hello"}}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject a $literal-wrapped scalar with no coercion", + ), + StageTestCase( + "arrival_stored_scalar", + docs=[{"_id": 1, "a": 42}], + pipeline=[{"$replaceRoot": {"newRoot": "$a"}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject a stored scalar field value with no coercion", + ), + StageTestCase( + "arrival_expression_object_to_array", + docs=[{"_id": 1, "a": {"k": 1}}], + pipeline=[{"$replaceRoot": {"newRoot": {"$objectToArray": "$a"}}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject an expression resolving to an array with no coercion", + ), +] + +# Property [Array Rejection]: an array newRoot in any form produces an error and +# is never unwrapped into an argument list. +REPLACEROOT_ARRAY_TESTS: list[StageTestCase] = [ + StageTestCase( + "array_literal_of_objects", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": [{"a": 1}]}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject a literal array without unwrapping it", + ), + StageTestCase( + "array_empty_literal", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": []}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject an empty array literal", + ), + StageTestCase( + "array_stored_reference", + docs=[{"_id": 1, "items": [{"a": 1}, {"b": 2}]}], + pipeline=[{"$replaceRoot": {"newRoot": "$items"}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject a stored array field reference", + ), +] + +# Property [Null and Missing Behavior]: a newRoot resolving to null or missing +# produces the non-object error, with no coercion or pass-through. +REPLACEROOT_NULL_MISSING_TESTS: list[StageTestCase] = [ + StageTestCase( + "null_literal", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": None}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject a literal null newRoot with no coercion", + ), + StageTestCase( + "null_stored_field", + docs=[{"_id": 1, "a": None}], + pipeline=[{"$replaceRoot": {"newRoot": "$a"}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject a stored null field value with no coercion", + ), + StageTestCase( + "null_ifnull", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"$ifNull": ["$nonexistent", None]}}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject an $ifNull resolving to null", + ), + StageTestCase( + "missing_field_reference", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": "$nonexistent"}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject a reference to a non-existent field", + ), + StageTestCase( + "missing_deep_path", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": "$nonexistent.b.c"}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject a deep path through a missing field", + ), + StageTestCase( + "missing_remove_variable", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": "$$REMOVE"}}], + error_code=REPLACE_ROOT_NON_OBJECT_ERROR, + msg="$replaceRoot should reject $$REMOVE used as the whole newRoot", + ), +] + +REPLACEROOT_NON_OBJECT_ERROR_TESTS = ( + REPLACEROOT_TYPE_STRICTNESS_TESTS + + REPLACEROOT_ARRIVAL_PATH_TESTS + + REPLACEROOT_ARRAY_TESTS + + REPLACEROOT_NULL_MISSING_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REPLACEROOT_NON_OBJECT_ERROR_TESTS)) +def test_replaceRoot_non_object_error_cases(collection, test_case: StageTestCase): + """Test $replaceRoot non object error cases.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_parse_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_parse_errors.py new file mode 100644 index 000000000..1fabf53f3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_parse_errors.py @@ -0,0 +1,177 @@ +"""Tests for the $replaceRoot aggregation stage.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, + FIELD_PATH_DOLLAR_PREFIX_ERROR, + FIELD_PATH_DOT_ERROR, + FIELD_PATH_EMPTY_COMPONENT_ERROR, + MISSING_FIELD_ERROR, + REPLACE_ROOT_SPEC_NOT_OBJECT_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + UNRECOGNIZED_EXPRESSION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Object-Literal Field-Name Errors]: a constructed newRoot object +# literal with an invalid key is rejected at parse time, each invalid-key kind +# hitting a distinct code, with no reclassification when nested in a sub-object. +REPLACEROOT_FIELD_NAME_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "fieldname_empty_key", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"": 1}}}], + error_code=FIELD_PATH_EMPTY_COMPONENT_ERROR, + msg="$replaceRoot should reject a constructed literal with an empty key", + ), + StageTestCase( + "fieldname_sole_dollar_key", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"$x": 1}}}], + error_code=UNRECOGNIZED_EXPRESSION_ERROR, + msg="$replaceRoot should reject a sole dollar-prefixed key as an unknown operator", + ), + StageTestCase( + "fieldname_dollar_key_with_sibling", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"good": 1, "$bad": 2}}}], + error_code=FIELD_PATH_DOLLAR_PREFIX_ERROR, + msg="$replaceRoot should reject a dollar-prefixed key alongside a plain sibling field", + ), + StageTestCase( + "fieldname_multiple_dollar_keys", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"$a": 1, "$b": 2}}}], + error_code=EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, + msg="$replaceRoot should reject multiple dollar-prefixed operator keys in one object", + ), + StageTestCase( + "fieldname_dotted_key", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"a.b": 1}}}], + error_code=FIELD_PATH_DOT_ERROR, + msg="$replaceRoot should reject a dotted key without expanding it into a path", + ), + StageTestCase( + "fieldname_nested_sole_dollar_key", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"a": {"$bad": 1}}}}], + error_code=UNRECOGNIZED_EXPRESSION_ERROR, + msg="$replaceRoot should reject a sole dollar-prefixed key in a sub-object identically", + ), + StageTestCase( + "fieldname_nested_dollar_key_with_sibling", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"a": {"good": 1, "$bad": 2}}}}], + error_code=FIELD_PATH_DOLLAR_PREFIX_ERROR, + msg="$replaceRoot should reject a dollar-prefixed key with a sibling in a sub-object", + ), + StageTestCase( + "fieldname_nested_dotted_key", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"a": {"b.c": 1}}}}], + error_code=FIELD_PATH_DOT_ERROR, + msg="$replaceRoot should reject a dotted key in a sub-object identically", + ), +] + +# Property [Stage Specification Errors]: a $replaceRoot specification that is not +# an object, that omits the newRoot field, or that carries an extra or wrong +# field is rejected at the specification-wrapper level. +REPLACEROOT_SPEC_ERROR_TESTS: list[StageTestCase] = [ + *( + StageTestCase( + f"spec_not_object_{tid}", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": val}], + error_code=REPLACE_ROOT_SPEC_NOT_OBJECT_ERROR, + msg=f"$replaceRoot should reject a {tid} specification that is not an object", + ) + for tid, val in [ + ("string", "foo"), + ("int32", 5), + ("int64", Int64(9_999_999_999)), + ("double", 3.5), + ("decimal128", Decimal128("123.456")), + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2]), + ("null", None), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1_700_000_000, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("^abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ), + StageTestCase( + "spec_missing_newroot", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {}}], + error_code=MISSING_FIELD_ERROR, + msg="$replaceRoot should reject a specification with no newRoot field", + ), + StageTestCase( + "spec_extra_field", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"newRoot": "$$ROOT", "extra": 1}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$replaceRoot should reject a specification with an extra unknown field", + ), + StageTestCase( + "spec_wrong_key", + docs=[{"_id": 1}], + pipeline=[{"$replaceRoot": {"foo": "$$ROOT"}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="$replaceRoot should reject a specification with a wrong key instead of newRoot", + ), +] + +REPLACEROOT_PARSE_ERROR_TESTS = REPLACEROOT_FIELD_NAME_ERROR_TESTS + REPLACEROOT_SPEC_ERROR_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REPLACEROOT_PARSE_ERROR_TESTS)) +def test_replaceRoot_parse_error_cases(collection, test_case: StageTestCase): + """Test $replaceRoot parse error cases.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_preservation.py b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_preservation.py new file mode 100644 index 000000000..97acf4985 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_preservation.py @@ -0,0 +1,181 @@ +"""Tests for the $replaceRoot aggregation stage.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import UUID_SUBTYPE + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import OrderedKeys + +# Property [Value and Type Preservation (Promoted)]: every BSON type held as a +# value inside a promoted stored sub-document is preserved verbatim with no +# value-level coercion. +REPLACEROOT_TYPE_PRESERVATION_PROMOTED_TESTS: list[StageTestCase] = [ + StageTestCase( + f"type_preserved_promoted_{tid}", + docs=[{"_id": 1, "wrap": {"v": val}}], + pipeline=[{"$replaceRoot": {"newRoot": "$wrap"}}], + expected=[{"v": val}], + msg=f"$replaceRoot should preserve a {tid} value verbatim in a promoted document", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 7), + ("int64", Int64(9_999_999_999)), + ("double", 3.5), + ("decimal128", Decimal128("123.456")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, 2, 3]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1_700_000_000, 1)), + ("regex", Regex("^abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Value and Type Preservation (Constructed)]: every BSON type held as a +# value inside a constructed object literal is preserved verbatim with no +# value-level coercion. +REPLACEROOT_TYPE_PRESERVATION_CONSTRUCTED_TESTS: list[StageTestCase] = [ + StageTestCase( + f"type_preserved_constructed_{tid}", + docs=[{"_id": 1, "v": val}], + pipeline=[{"$replaceRoot": {"newRoot": {"v": "$v"}}}], + expected=[{"v": val}], + msg=f"$replaceRoot should preserve a {tid} value verbatim in a constructed document", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 7), + ("int64", Int64(9_999_999_999)), + ("double", 3.5), + ("decimal128", Decimal128("123.456")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, 2, 3]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1_700_000_000, 1)), + ("regex", Regex("^abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Binary Subtype Preservation]: a binary value's subtype is preserved +# with no normalization, so subtype-0 and subtype-4 (UUID) binaries round-trip +# unchanged whether promoted or constructed. +REPLACEROOT_BINARY_SUBTYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "binary_subtype0_promoted", + docs=[{"_id": 1, "wrap": {"v": Binary(b"\x01\x02\x03", 0)}}], + pipeline=[{"$replaceRoot": {"newRoot": "$wrap"}}], + expected=[{"v": b"\x01\x02\x03"}], + msg="$replaceRoot should preserve a subtype-0 binary verbatim in a promoted document", + ), + StageTestCase( + "binary_subtype0_constructed", + docs=[{"_id": 1, "v": Binary(b"\x01\x02\x03", 0)}], + pipeline=[{"$replaceRoot": {"newRoot": {"v": "$v"}}}], + expected=[{"v": b"\x01\x02\x03"}], + msg="$replaceRoot should preserve a subtype-0 binary verbatim in a constructed document", + ), + StageTestCase( + "binary_subtype4_promoted", + docs=[{"_id": 1, "wrap": {"v": Binary(bytes(range(16)), UUID_SUBTYPE)}}], + pipeline=[{"$replaceRoot": {"newRoot": "$wrap"}}], + expected=[{"v": Binary(bytes(range(16)), UUID_SUBTYPE)}], + msg="$replaceRoot should preserve a subtype-4 binary subtype in a promoted document", + ), + StageTestCase( + "binary_subtype4_constructed", + docs=[{"_id": 1, "v": Binary(bytes(range(16)), UUID_SUBTYPE)}], + pipeline=[{"$replaceRoot": {"newRoot": {"v": "$v"}}}], + expected=[{"v": Binary(bytes(range(16)), UUID_SUBTYPE)}], + msg="$replaceRoot should preserve a subtype-4 binary subtype in a constructed document", + ), +] + +# Property [Field Order Preservation]: the field order of the resolved object is +# preserved verbatim, with no reordering or alphabetization, whether promoted or +# constructed. +REPLACEROOT_FIELD_ORDER_TESTS: list[StageTestCase] = [ + StageTestCase( + "field_order_promoted_embedded_doc", + docs=[{"_id": 1, "data": {"zebra": 1, "apple": 2, "mango": 3}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected={"": OrderedKeys(["zebra", "apple", "mango"])}, + msg="$replaceRoot should preserve the field order of a promoted embedded document", + ), + StageTestCase( + "field_order_constructed_literal_mixed", + docs=[{"_id": 1, "a": 10}], + pipeline=[ + { + "$replaceRoot": { + "newRoot": {"zebra": "$a", "apple": "lit", "mango": {"$add": [1, 2]}} + } + } + ], + expected={"": OrderedKeys(["zebra", "apple", "mango"])}, + msg="$replaceRoot should preserve declaration order of a constructed mixed literal", + ), +] + +REPLACEROOT_PRESERVATION_TESTS = ( + REPLACEROOT_TYPE_PRESERVATION_PROMOTED_TESTS + + REPLACEROOT_TYPE_PRESERVATION_CONSTRUCTED_TESTS + + REPLACEROOT_BINARY_SUBTYPE_TESTS + + REPLACEROOT_FIELD_ORDER_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REPLACEROOT_PRESERVATION_TESTS)) +def test_replaceRoot_preservation_cases(collection, test_case: StageTestCase): + """Test $replaceRoot preservation cases.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_replacement_semantics.py b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_replacement_semantics.py new file mode 100644 index 000000000..d12b0d563 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/replaceRoot/test_replaceRoot_replacement_semantics.py @@ -0,0 +1,306 @@ +"""Tests for the $replaceRoot aggregation stage.""" + +from __future__ import annotations + +import pytest +from bson import ( + Regex, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +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 MISSING + +# Property [Core Replacement Behavior]: the input document is replaced entirely +# by the object newRoot resolves to against the pre-replacement input; no +# original field survives unless the resolved object provides it. +REPLACEROOT_CORE_TESTS: list[StageTestCase] = [ + StageTestCase( + "core_promote_embedded_doc", + docs=[{"_id": 1, "data": {"_id": 1, "name": "Alice", "age": 30}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{"_id": 1, "name": "Alice", "age": 30}], + msg="$replaceRoot should promote a referenced embedded document to the top level", + ), + StageTestCase( + "core_replaces_entirely_no_input_fields", + docs=[{"_id": 1, "data": {"_id": 1, "x": 1}, "other": "keep_out"}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{"_id": 1, "x": 1}], + msg="$replaceRoot should drop all original input fields not present in the resolved object", + ), + StageTestCase( + "core_constructed_document", + docs=[{"_id": 1, "a": 2, "b": 3}], + pipeline=[ + { + "$replaceRoot": { + "newRoot": {"_id": "$_id", "sum": {"$add": ["$a", "$b"]}, "label": "$_id"} + } + } + ], + expected=[{"_id": 1, "sum": 5, "label": 1}], + msg="$replaceRoot should replace the input with a constructed document evaluated on input", + ), +] + +# Property [Identity]: $$ROOT and $$CURRENT as newRoot return the input document +# unchanged, including its original _id. +REPLACEROOT_IDENTITY_TESTS: list[StageTestCase] = [ + StageTestCase( + "identity_root", + docs=[{"_id": 1, "a": 10, "b": {"c": 2}}], + pipeline=[{"$replaceRoot": {"newRoot": "$$ROOT"}}], + expected=[{"_id": 1, "a": 10, "b": {"c": 2}}], + msg="$replaceRoot with $$ROOT should return the input document unchanged", + ), + StageTestCase( + "identity_current", + docs=[{"_id": 1, "a": 10, "b": {"c": 2}}], + pipeline=[{"$replaceRoot": {"newRoot": "$$CURRENT"}}], + expected=[{"_id": 1, "a": 10, "b": {"c": 2}}], + msg="$replaceRoot with $$CURRENT should return the input document unchanged", + ), +] + +# Property [_id Handling]: the stage neither auto-generates nor validates _id; a +# resolved object's _id passes through unchanged, with no type or cross-document +# uniqueness enforcement. +REPLACEROOT_ID_HANDLING_TESTS: list[StageTestCase] = [ + StageTestCase( + "idhandling_no_id_not_generated", + docs=[{"_id": 1, "data": {"name": "Alice"}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{"name": "Alice"}], + msg="$replaceRoot should not auto-generate an _id when the resolved object has none", + ), + StageTestCase( + "idhandling_explicit_id_preserved", + docs=[{"_id": 1, "data": {"_id": 99, "name": "Bob"}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{"_id": 99, "name": "Bob"}], + msg="$replaceRoot should preserve an explicit _id in the resolved object", + ), + StageTestCase( + "idhandling_array_id_no_type_enforcement", + docs=[{"_id": 1, "data": {"_id": [1, 2, 3], "name": "x"}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{"_id": [1, 2, 3], "name": "x"}], + msg="$replaceRoot should pass through an array _id with no type enforcement", + ), + StageTestCase( + "idhandling_null_id_no_type_enforcement", + docs=[{"_id": 1, "data": {"_id": None, "name": "x"}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{"_id": None, "name": "x"}], + msg="$replaceRoot should pass through a null _id with no type enforcement", + ), + StageTestCase( + "idhandling_regex_id_no_type_enforcement", + docs=[{"_id": 1, "data": {"_id": Regex("abc", "i"), "name": "x"}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{"_id": Regex("abc", "i"), "name": "x"}], + msg="$replaceRoot should pass through a regex _id with no type enforcement", + ), + StageTestCase( + "idhandling_duplicate_id_no_uniqueness_enforcement", + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}, {"_id": 3, "a": 3}], + pipeline=[{"$replaceRoot": {"newRoot": {"_id": "same", "v": "$a"}}}], + expected=[ + {"_id": "same", "v": 1}, + {"_id": "same", "v": 2}, + {"_id": "same", "v": 3}, + ], + msg="$replaceRoot should emit all documents sharing one _id with no uniqueness error", + ), +] + +# Property [Object-Literal Field-Value Semantics]: a field whose value in the +# constructed newRoot literal is $$REMOVE or a missing reference is omitted from +# the result; a literal whose every field is omitted yields the empty document; +# and within a nested sub-object the (now empty) sub-object is kept rather than +# dropping its parent. +REPLACEROOT_OBJECT_LITERAL_FIELD_VALUE_TESTS: list[StageTestCase] = [ + StageTestCase( + "objlit_remove_value_omits_field", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"a": "$a", "b": "$$REMOVE"}}}], + expected=[{"a": 1}], + msg="$replaceRoot should omit a field whose value is $$REMOVE", + ), + StageTestCase( + "objlit_missing_value_omits_field", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"a": "$a", "b": "$nonexistent"}}}], + expected=[{"a": 1}], + msg="$replaceRoot should omit a field whose value references a missing field", + ), + StageTestCase( + "objlit_only_remove_value_yields_empty", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"b": "$$REMOVE"}}}], + expected=[{}], + msg="$replaceRoot should yield the empty document when the sole field's value is $$REMOVE", + ), + StageTestCase( + "objlit_only_missing_value_yields_empty", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"b": "$nonexistent"}}}], + expected=[{}], + msg="$replaceRoot should yield empty document when the sole field is a missing reference", + ), + StageTestCase( + "objlit_remove_value_omits_present_field", + docs=[{"_id": 1, "a": 1, "b": 2}], + pipeline=[{"$replaceRoot": {"newRoot": {"a": "$a", "b": "$$REMOVE"}}}], + expected=[{"a": 1}], + msg="$replaceRoot should omit a field set to $$REMOVE that exists in the input", + ), + StageTestCase( + "objlit_nested_sole_remove_keeps_subobject", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"a": {"b": "$$REMOVE"}}}}], + expected=[{"a": {}}], + msg="$replaceRoot should keep an emptied sub-object rather than dropping its parent field", + ), + StageTestCase( + "objlit_nested_partial_remove_keeps_siblings", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"a": {"x": "$a", "y": "$$REMOVE"}}}}], + expected=[{"a": {"x": 1}}], + msg="$replaceRoot should keep surviving siblings when one sub-object field is omitted", + ), +] + +# Property [Promoted Stored-Document Field Names]: when a stored sub-document is +# promoted via a field reference, its keys are promoted to the top level +# verbatim with no field-name validation, even when those keys contain a dot or +# begin with a dollar sign. +REPLACEROOT_PROMOTED_FIELD_NAMES_TESTS: list[StageTestCase] = [ + StageTestCase( + "promoted_field_names_dotted_key", + docs=[{"_id": 1, "data": {"a.b": 1, "x": 2}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{"a.b": 1, "x": 2}], + msg="$replaceRoot should promote a stored dotted key verbatim without validation", + ), + StageTestCase( + "promoted_field_names_dollar_key", + docs=[{"_id": 1, "data": {"$x": 5}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{"$x": 5}], + msg="$replaceRoot should promote a stored dollar-prefixed key verbatim without validation", + ), +] + +# Property [Expression Arguments]: newRoot promotes whatever object an +# object-resolving expression yields independent of which operator produced it. +REPLACEROOT_EXPRESSION_ARGUMENT_TESTS: list[StageTestCase] = [ + StageTestCase( + "expr_arg_object_producing_operator", + docs=[{"_id": 1, "a": {"x": 1}, "b": {"y": 2}}], + pipeline=[{"$replaceRoot": {"newRoot": {"$mergeObjects": ["$a", "$b"]}}}], + expected=[{"x": 1, "y": 2}], + msg="$replaceRoot should promote the object an object-producing expression yields", + ), + StageTestCase( + "expr_arg_literal_object", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {"$literal": {"k1": 1, "k2": 2}}}}], + expected=[{"k1": 1, "k2": 2}], + msg="$replaceRoot should promote the object wrapped in $literal", + ), +] + +# Property [Accepted Input]: an empty object is accepted and yields an empty +# output document, whether supplied as a literal newRoot or a stored reference. +REPLACEROOT_ACCEPTED_INPUT_TESTS: list[StageTestCase] = [ + StageTestCase( + "accepted_empty_object_literal", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$replaceRoot": {"newRoot": {}}}], + expected=[{}], + msg="$replaceRoot should accept an empty object literal and yield the empty document", + ), + StageTestCase( + "accepted_stored_empty_object", + docs=[{"_id": 1, "data": {}}], + pipeline=[{"$replaceRoot": {"newRoot": "$data"}}], + expected=[{}], + msg="$replaceRoot should accept a stored empty object and yield the empty document", + ), +] + +# Property [Consecutive Stages]: consecutive $replaceRoot stages compose, each +# promoting a sub-document of the shape produced by the previous one. +REPLACEROOT_CONSECUTIVE_TESTS: list[StageTestCase] = [ + StageTestCase( + "consecutive_two_stages", + docs=[{"_id": 1, "a": {"b": {"c": 42}}}], + pipeline=[ + {"$replaceRoot": {"newRoot": "$a"}}, + {"$replaceRoot": {"newRoot": "$b"}}, + ], + expected=[{"c": 42}], + msg="consecutive $replaceRoot stages should each promote a sub-document of the prior root", + ), +] + +# Property [Empty and Non-Existent Collections]: running $replaceRoot on an +# empty collection or a non-existent collection returns an empty result with no +# error. +REPLACEROOT_EMPTY_COLLECTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "empty_collection", + docs=[], + pipeline=[{"$replaceRoot": {"newRoot": MISSING}}], + expected=[], + msg="$replaceRoot on an empty collection should return an empty result", + ), + StageTestCase( + "nonexistent_collection", + docs=None, + pipeline=[{"$replaceRoot": {"newRoot": MISSING}}], + expected=[], + msg="$replaceRoot on a non-existent collection should return an empty result", + ), +] + +REPLACEROOT_REPLACEMENT_SEMANTICS_TESTS = ( + REPLACEROOT_CORE_TESTS + + REPLACEROOT_IDENTITY_TESTS + + REPLACEROOT_ID_HANDLING_TESTS + + REPLACEROOT_OBJECT_LITERAL_FIELD_VALUE_TESTS + + REPLACEROOT_PROMOTED_FIELD_NAMES_TESTS + + REPLACEROOT_EXPRESSION_ARGUMENT_TESTS + + REPLACEROOT_ACCEPTED_INPUT_TESTS + + REPLACEROOT_CONSECUTIVE_TESTS + + REPLACEROOT_EMPTY_COLLECTION_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REPLACEROOT_REPLACEMENT_SEMANTICS_TESTS)) +def test_replaceRoot_replacement_semantics_cases(collection, test_case: StageTestCase): + """Test $replaceRoot replacement semantics cases.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_lookup.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_lookup.py index 5d6d75d1a..aaff93e22 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_lookup.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_lookup.py @@ -319,6 +319,37 @@ expected=[{"_id": 10, "ff": "a", "x": 42}], msg="$replaceRoot after $lookup should promote first joined doc to root", ), + LookupTestCase( + "lookup_unwind_replace_root_merge_objects", + docs=[{"_id": 1, "lf": "a", "q": 3}], + foreign_docs=[ + {"_id": 10, "ff": "a", "label": "X"}, + {"_id": 20, "ff": "b", "label": "Y"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "lf", + "foreignField": "ff", + "as": "j", + } + }, + {"$unwind": "$j"}, + {"$replaceRoot": {"newRoot": {"$mergeObjects": ["$$ROOT", "$j"]}}}, + ], + expected=[ + { + "_id": 10, + "lf": "a", + "q": 3, + "j": {"_id": 10, "ff": "a", "label": "X"}, + "ff": "a", + "label": "X", + }, + ], + msg="$replaceRoot with $mergeObjects should merge a joined doc onto the original root", + ), # Multi-stage combinations. LookupTestCase( "match_sort_then_lookup", diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_replaceRoot.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_replaceRoot.py new file mode 100644 index 000000000..b369fd767 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_replaceRoot.py @@ -0,0 +1,186 @@ +"""Tests for $replaceRoot composing with other stages at different pipeline positions.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Filtering Composition]: $replaceRoot composes with $match in either +# position, and only the promoted fields are visible to a following $match. +REPLACEROOT_POSITION_FILTER_TESTS: list[StageTestCase] = [ + StageTestCase( + "filter_match_then_replace", + docs=[ + {"_id": 1, "a": 10, "inner": {"x": 1}}, + {"_id": 2, "a": 30, "inner": {"x": 2}}, + ], + pipeline=[ + {"$match": {"a": {"$gt": 15}}}, + {"$replaceRoot": {"newRoot": "$inner"}}, + ], + expected=[{"x": 2}], + msg="$replaceRoot should promote only documents kept by a preceding $match", + ), + StageTestCase( + "filter_replace_then_match_new_field", + docs=[ + {"_id": 1, "inner": {"x": 10}}, + {"_id": 2, "inner": {"x": 20}}, + ], + pipeline=[ + {"$replaceRoot": {"newRoot": "$inner"}}, + {"$match": {"x": {"$gt": 15}}}, + ], + expected=[{"x": 20}], + msg="$replaceRoot should expose promoted fields to a following $match", + ), + StageTestCase( + "filter_replace_then_match_dropped_field", + docs=[{"_id": 1, "inner": {"x": 10}, "keep": "orig"}], + pipeline=[ + {"$replaceRoot": {"newRoot": "$inner"}}, + {"$match": {"keep": "orig"}}, + ], + expected=[], + msg="$match on a field dropped by a preceding $replaceRoot should return empty", + ), +] + +# Property [Reshape Composition]: $sort and $project after $replaceRoot operate +# on the promoted document shape, not the original input. +REPLACEROOT_POSITION_RESHAPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "reshape_replace_then_sort", + docs=[ + {"_id": 1, "x": 1, "inner": {"x": 30}}, + {"_id": 2, "x": 2, "inner": {"x": 10}}, + {"_id": 3, "x": 3, "inner": {"x": 20}}, + ], + pipeline=[ + {"$replaceRoot": {"newRoot": "$inner"}}, + {"$sort": {"x": 1}}, + ], + expected=[{"x": 10}, {"x": 20}, {"x": 30}], + msg="$sort after $replaceRoot should order by the promoted field, not the outer one", + ), + StageTestCase( + "reshape_replace_then_project", + docs=[{"_id": 1, "x": 99, "inner": {"x": 10, "y": 20, "z": 30}}], + pipeline=[ + {"$replaceRoot": {"newRoot": "$inner"}}, + {"$project": {"x": 1}}, + ], + expected=[{"x": 10}], + msg="$project after $replaceRoot should keep the promoted field, not the outer one", + ), + StageTestCase( + "reshape_merge_objects_then_project", + docs=[{"_id": 1, "a": 1, "extra": {"b": 2}}], + pipeline=[ + {"$replaceRoot": {"newRoot": {"$mergeObjects": ["$$ROOT", "$extra"]}}}, + {"$project": {"a": 1, "b": 1}}, + ], + expected=[{"_id": 1, "a": 1, "b": 2}], + msg="$project should see fields grafted onto the input by $mergeObjects in $replaceRoot", + ), +] + +# Property [Grouping Composition]: a following $group keys on the promoted _id +# or its absence, and a preceding $group's accumulator output can be promoted. +REPLACEROOT_POSITION_GROUP_TESTS: list[StageTestCase] = [ + StageTestCase( + "group_replace_drops_id_then_group", + docs=[ + {"_id": 1, "inner": {"x": 10}}, + {"_id": 2, "inner": {"x": 20}}, + ], + pipeline=[ + {"$replaceRoot": {"newRoot": "$inner"}}, + {"$group": {"_id": "$_id", "c": {"$sum": 1}}}, + ], + expected=[{"_id": None, "c": 2}], + msg="$group after $replaceRoot should bucket under null when the promoted root lacks _id", + ), + StageTestCase( + "group_replace_promotes_own_id_then_group", + docs=[ + {"_id": 1, "inner": {"_id": 99, "x": 10}}, + {"_id": 2, "inner": {"_id": 99, "x": 20}}, + ], + pipeline=[ + {"$replaceRoot": {"newRoot": "$inner"}}, + {"$group": {"_id": "$_id", "c": {"$sum": 1}}}, + ], + expected=[{"_id": 99, "c": 2}], + msg="$group after $replaceRoot should bucket on the _id carried by the promoted document", + ), + StageTestCase( + "group_then_replace_promotes_accumulator", + docs=[ + {"_id": 1, "cat": "a", "v": 5}, + {"_id": 2, "cat": "a", "v": 7}, + {"_id": 3, "cat": "b", "v": 3}, + ], + pipeline=[ + {"$group": {"_id": "$cat", "stats": {"$push": "$v"}}}, + {"$replaceRoot": {"newRoot": {"cat": "$_id", "values": "$stats"}}}, + {"$sort": {"cat": 1}}, + ], + expected=[ + {"cat": "a", "values": [5, 7]}, + {"cat": "b", "values": [3]}, + ], + msg="$replaceRoot should promote a document constructed from $group accumulator output", + ), +] + +# Property [Flattening Composition]: $replaceRoot composes with $unwind to +# promote each element of an array of sub-documents to the top level, the +# canonical array-flattening pipeline idiom. +REPLACEROOT_POSITION_FLATTEN_TESTS: list[StageTestCase] = [ + StageTestCase( + "flatten_unwind_then_replace", + docs=[{"_id": 1, "items": [{"name": "a"}, {"name": "b"}]}], + pipeline=[ + {"$unwind": "$items"}, + {"$replaceRoot": {"newRoot": "$items"}}, + ], + expected=[{"name": "a"}, {"name": "b"}], + msg="$replaceRoot should promote each array element produced by a preceding $unwind", + ), +] + +REPLACEROOT_POSITION_TESTS: list[StageTestCase] = ( + REPLACEROOT_POSITION_FILTER_TESTS + + REPLACEROOT_POSITION_RESHAPE_TESTS + + REPLACEROOT_POSITION_GROUP_TESTS + + REPLACEROOT_POSITION_FLATTEN_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REPLACEROOT_POSITION_TESTS)) +def test_stages_position_replaceRoot_cases(collection, test_case: StageTestCase): + """Test $replaceRoot composing with other stages at different pipeline positions.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/utils/shared_limits.py b/documentdb_tests/compatibility/tests/core/operator/stages/utils/shared_limits.py index fca349799..e9fb7ebe8 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/utils/shared_limits.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/utils/shared_limits.py @@ -7,6 +7,13 @@ # documents and is independent of the promoting stage. MAX_STORED_NESTING_DEPTH = 179 +# The maximum BSON nesting depth a command document may reach before the server +# rejects it with an Overflow error. This is a global command-parse limit, not a +# stage behavior: find/distinct bottom out at this same depth. A stage's +# accepted literal depth is this ceiling minus the wrapper levels its command +# path consumes. +MAX_COMMAND_NESTING_DEPTH = 200 + # The output document size limit, in bytes, that both stages enforce on their # result: 16 MiB plus 16 KiB. It is higher than the 16 MiB standard insert # limit, so a stored input can stay within the standard limit while the diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 3b171aa31..aa27399c7 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -300,6 +300,7 @@ BUCKET_GROUPBY_NOT_EXPRESSION_ERROR = 40202 QUERY_METADATA_NOT_AVAILABLE_ERROR = 40218 REPLACE_ROOT_NON_OBJECT_ERROR = 40228 +REPLACE_ROOT_SPEC_NOT_OBJECT_ERROR = 40229 BUCKET_OUTPUT_NOT_ACCUMULATOR_ERROR = 40234 BUCKET_OUTPUT_DOT_ERROR = 40235 BUCKET_OUTPUT_DOLLAR_PREFIX_ERROR = 40236 From 3eb70a6894569fa59016b38745a72d0a548ba723 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 24 Jun 2026 18:18:47 -0700 Subject: [PATCH 09/51] Add redact aggregation stage tests (#584) Signed-off-by: Daniel Frankcom --- .../core/operator/stages/redact/__init__.py | 0 .../redact/test_redact_descend_structure.py | 82 +++++ .../stages/redact/test_redact_expressions.py | 322 ++++++++++++++++++ .../stages/redact/test_redact_output.py | 162 +++++++++ .../stages/redact/test_redact_scoping.py | 170 +++++++++ .../stages/redact/test_redact_sentinels.py | 203 +++++++++++ .../stages/redact/test_redact_validation.py | 314 +++++++++++++++++ .../stages/test_stages_position_redact.py | 129 +++++++ documentdb_tests/framework/error_codes.py | 1 + 9 files changed, 1383 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/redact/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_descend_structure.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_output.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_scoping.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_sentinels.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_redact.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/redact/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/redact/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_descend_structure.py b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_descend_structure.py new file mode 100644 index 000000000..37a9505e3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_descend_structure.py @@ -0,0 +1,82 @@ +"""Tests for $redact $$DESCEND structural traversal. + +Covers deep nesting, large arrays, and empty-container retention. +""" + +from __future__ import annotations + +from functools import reduce +from typing import Any, cast + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Recursion Depth and Scale]: $redact descends through deep nesting and +# large arrays of embedded documents without error or truncation. +REDACT_STRUCTURAL_TESTS: list[StageTestCase] = [ + StageTestCase( + "structural_large_array_descended_element_wise", + docs=[{"_id": 1, "items": [{"n": i, "sub": {"secret": True}} for i in range(10_000)]}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, "items": [{"n": i} for i in range(10_000)]}], + msg="$redact under $$DESCEND should descend element-wise into a large array of " + "embedded documents without error", + ), + StageTestCase( + "structural_deep_nesting_depth_99", + docs=[{"_id": 1, **reduce(lambda v, k: {k: v}, ["a"] * 99, cast(Any, {"secret": True}))}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, **reduce(lambda v, k: {k: v}, ["a"] * 98, cast(Any, {}))}], + msg="$redact under $$DESCEND should descend through 99 nesting levels and " + "prune only the leaf without truncation or error", + ), +] + +# Property [Empty Structure Retention]: an already-empty embedded document or +# array of empty documents is retained under $$DESCEND. +REDACT_EMPTY_STRUCTURE_TESTS: list[StageTestCase] = [ + StageTestCase( + "structural_empty_embedded_document_kept", + docs=[{"_id": 1, "child": {}}], + pipeline=[{"$redact": "$$DESCEND"}], + expected=[{"_id": 1, "child": {}}], + msg="$redact under $$DESCEND should keep an empty embedded document", + ), + StageTestCase( + "structural_array_of_empty_documents_kept", + docs=[{"_id": 1, "items": [{}, {}]}], + pipeline=[{"$redact": "$$DESCEND"}], + expected=[{"_id": 1, "items": [{}, {}]}], + msg="$redact under $$DESCEND should keep an array of empty documents", + ), +] + +REDACT_DESCEND_STRUCTURE_TESTS = REDACT_STRUCTURAL_TESTS + REDACT_EMPTY_STRUCTURE_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REDACT_DESCEND_STRUCTURE_TESTS)) +def test_redact_descend_structure_cases(collection, test_case: StageTestCase): + """Test $redact $$DESCEND traversal of deep nesting, large arrays, and empty containers.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_expressions.py b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_expressions.py new file mode 100644 index 000000000..7f3756254 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_expressions.py @@ -0,0 +1,322 @@ +"""Tests for expression support inside $redact. + +Covers the predicate operators commonly used in redaction conditions and the +expression forms that resolve to a sentinel. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +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 MISSING + +# Property [Condition Operator Support]: a predicate operator evaluates per +# document and selects the $$KEEP/$$PRUNE branch of a conditional. +REDACT_EXPRESSION_TESTS: list[StageTestCase] = [ + # Comparisons. + StageTestCase( + "expr_eq", + docs=[{"_id": 1, "status": "public"}, {"_id": 2, "status": "private"}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$status", "public"]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "status": "public"}], + msg="$eq should drive $redact, keeping documents whose status equals the allowed value", + ), + StageTestCase( + "expr_ne", + docs=[{"_id": 1, "status": "public"}, {"_id": 2, "status": "secret"}], + pipeline=[{"$redact": {"$cond": [{"$ne": ["$status", "secret"]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "status": "public"}], + msg="$ne should drive $redact, keeping documents whose status differs from the blocked one", + ), + StageTestCase( + "expr_gt", + docs=[{"_id": 1, "level": 5}, {"_id": 2, "level": 3}], + pipeline=[{"$redact": {"$cond": [{"$gt": ["$level", 3]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "level": 5}], + msg="$gt should drive $redact, keeping documents strictly above the threshold level", + ), + StageTestCase( + "expr_gte", + docs=[{"_id": 1, "level": 5}, {"_id": 2, "level": 4}], + pipeline=[{"$redact": {"$cond": [{"$gte": ["$level", 5]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "level": 5}], + msg="$gte should drive $redact, keeping documents at or above the threshold level", + ), + StageTestCase( + "expr_lt", + docs=[{"_id": 1, "level": 2}, {"_id": 2, "level": 3}], + pipeline=[{"$redact": {"$cond": [{"$lt": ["$level", 3]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "level": 2}], + msg="$lt should drive $redact, keeping documents strictly below the threshold level", + ), + StageTestCase( + "expr_lte", + docs=[{"_id": 1, "level": 2}, {"_id": 2, "level": 5}], + pipeline=[{"$redact": {"$cond": [{"$lte": ["$level", 2]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "level": 2}], + msg="$lte should drive $redact, keeping documents at or below the threshold level", + ), + # Boolean combiners. + StageTestCase( + "expr_and", + docs=[ + {"_id": 1, "status": "public", "level": 2}, + {"_id": 2, "status": "secret", "level": 2}, + {"_id": 3, "status": "public", "level": 9}, + ], + pipeline=[ + { + "$redact": { + "$cond": [ + {"$and": [{"$eq": ["$status", "public"]}, {"$lt": ["$level", 5]}]}, + "$$KEEP", + "$$PRUNE", + ] + } + } + ], + expected=[{"_id": 1, "status": "public", "level": 2}], + msg="$and should drive $redact, keeping documents satisfying every clause", + ), + StageTestCase( + "expr_or", + docs=[ + {"_id": 1, "status": "public", "level": 9}, + {"_id": 2, "status": "secret", "level": 2}, + {"_id": 3, "status": "secret", "level": 9}, + ], + pipeline=[ + { + "$redact": { + "$cond": [ + {"$or": [{"$eq": ["$status", "public"]}, {"$lt": ["$level", 5]}]}, + "$$KEEP", + "$$PRUNE", + ] + } + } + ], + expected=[ + {"_id": 1, "status": "public", "level": 9}, + {"_id": 2, "status": "secret", "level": 2}, + ], + msg="$or should drive $redact, keeping documents satisfying any clause", + ), + StageTestCase( + "expr_not", + docs=[{"_id": 1, "status": "public"}, {"_id": 2, "status": "secret"}], + pipeline=[ + { + "$redact": { + "$cond": [{"$not": [{"$eq": ["$status", "secret"]}]}, "$$KEEP", "$$PRUNE"] + } + } + ], + expected=[{"_id": 1, "status": "public"}], + msg="$not should drive $redact, keeping documents that fail the negated clause", + ), + # Membership and set-based access control. + StageTestCase( + "expr_in", + docs=[{"_id": 1, "status": "public"}, {"_id": 2, "status": "secret"}], + pipeline=[ + { + "$redact": { + "$cond": [{"$in": ["$status", ["public", "shared"]]}, "$$KEEP", "$$PRUNE"] + } + } + ], + expected=[{"_id": 1, "status": "public"}], + msg="$in should drive $redact, keeping documents whose status is among the allowed values", + ), + StageTestCase( + "expr_size", + docs=[{"_id": 1, "tags": ["A"]}, {"_id": 2, "tags": []}], + pipeline=[{"$redact": {"$cond": [{"$gt": [{"$size": "$tags"}, 0]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "tags": ["A"]}], + msg="$size should drive $redact, keeping documents whose tag array is non-empty", + ), + StageTestCase( + "expr_setIntersection", + docs=[{"_id": 1, "tags": ["A", "X"]}, {"_id": 2, "tags": ["Y", "Z"]}], + pipeline=[ + { + "$redact": { + "$cond": [ + {"$gt": [{"$size": {"$setIntersection": ["$tags", ["A", "B"]]}}, 0]}, + "$$KEEP", + "$$PRUNE", + ] + } + } + ], + expected=[{"_id": 1, "tags": ["A", "X"]}], + msg="$setIntersection should drive $redact, keeping documents sharing a tag with the " + "allowed set", + ), + StageTestCase( + "expr_setIsSubset", + docs=[ + {"_id": 1, "required": ["read"], "granted": ["read", "write"]}, + {"_id": 2, "required": ["admin"], "granted": ["read"]}, + ], + pipeline=[ + { + "$redact": { + "$cond": [{"$setIsSubset": ["$required", "$granted"]}, "$$KEEP", "$$PRUNE"] + } + } + ], + expected=[{"_id": 1, "required": ["read"], "granted": ["read", "write"]}], + msg="$setIsSubset should drive $redact, keeping documents whose required tags are all " + "granted", + ), + StageTestCase( + "expr_anyElementTrue", + docs=[{"_id": 1, "flags": [False, True]}, {"_id": 2, "flags": [False, False]}], + pipeline=[{"$redact": {"$cond": [{"$anyElementTrue": ["$flags"]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "flags": [False, True]}], + msg="$anyElementTrue should drive $redact, keeping documents with any flag set", + ), + StageTestCase( + "expr_allElementsTrue", + docs=[{"_id": 1, "flags": [True, True]}, {"_id": 2, "flags": [True, False]}], + pipeline=[{"$redact": {"$cond": [{"$allElementsTrue": ["$flags"]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "flags": [True, True]}], + msg="$allElementsTrue should drive $redact, keeping documents with every flag set", + ), + # Type-based. + StageTestCase( + "expr_type", + docs=[{"_id": 1, "level": 5}, {"_id": 2, "level": "high"}], + pipeline=[ + {"$redact": {"$cond": [{"$eq": [{"$type": "$level"}, "int"]}, "$$KEEP", "$$PRUNE"]}} + ], + expected=[{"_id": 1, "level": 5}], + msg="$type should drive $redact, keeping documents whose level field is the expected type", + ), +] + +# Property [Sentinel Resolution]: an expression that resolves directly to a +# sentinel drives the stage's behavior. +REDACT_SENTINEL_RESOLUTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "sentinel_bare_keep", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": "$$KEEP"}], + expected=[{"_id": 1, "a": 1}], + msg="$redact should keep the whole document when the argument resolves to $$KEEP", + ), + StageTestCase( + "sentinel_bare_prune", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": "$$PRUNE"}], + expected=[], + msg="$redact should drop the document when the argument resolves to $$PRUNE", + ), + StageTestCase( + "sentinel_bare_descend", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": "$$DESCEND"}], + expected=[{"_id": 1, "a": 1}], + msg="$redact should descend and retain all content when the argument resolves to $$DESCEND", + ), + StageTestCase( + "sentinel_switch_branches", + docs=[{"_id": 1, "level": 5}, {"_id": 2, "level": 3}, {"_id": 3, "level": 1}], + pipeline=[ + { + "$redact": { + "$switch": { + "branches": [ + {"case": {"$eq": ["$level", 5]}, "then": "$$KEEP"}, + {"case": {"$eq": ["$level", 3]}, "then": "$$PRUNE"}, + ], + "default": "$$KEEP", + } + } + } + ], + expected=[{"_id": 1, "level": 5}, {"_id": 3, "level": 1}], + msg="$redact should honor a sentinel chosen by a $switch branch or its default", + ), + StageTestCase( + "sentinel_let_user_variable", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": {"$let": {"vars": {"x": "$$PRUNE"}, "in": "$$x"}}}], + expected=[], + msg="$redact should honor a sentinel bound to a user variable and returned by $let", + ), + StageTestCase( + "sentinel_map_as_variable_body", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": {"$first": {"$map": {"input": [1], "as": "y", "in": "$$PRUNE"}}}}], + expected=[], + msg="$redact should honor a sentinel returned from a $map as-variable body", + ), + StageTestCase( + "sentinel_ifnull_coalesce", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": {"$ifNull": [MISSING, "$$PRUNE"]}}], + expected=[], + msg="$redact should honor a sentinel produced by $ifNull coalescing", + ), + StageTestCase( + "sentinel_array_elem_at_prune", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": {"$arrayElemAt": [["$$PRUNE"], 0]}}], + expected=[], + msg="$redact should honor a sentinel extracted from an array by $arrayElemAt", + ), + StageTestCase( + "sentinel_get_field_prune", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": {"$getField": {"field": "f", "input": {"f": "$$PRUNE"}}}}], + expected=[], + msg="$redact should honor a sentinel extracted from a document by $getField", + ), + StageTestCase( + "sentinel_first_prune", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": {"$first": [["$$PRUNE"]]}}], + expected=[], + msg="$redact should honor a sentinel extracted from an array by $first", + ), + StageTestCase( + "sentinel_last_prune", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": {"$last": [["$$PRUNE"]]}}], + expected=[], + msg="$redact should honor a sentinel extracted from an array by $last", + ), +] + +REDACT_EXPRESSION_FILE_TESTS = REDACT_EXPRESSION_TESTS + REDACT_SENTINEL_RESOLUTION_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REDACT_EXPRESSION_FILE_TESTS)) +def test_redact_expression_cases(collection, test_case: StageTestCase): + """Test expression operator support and sentinel resolution inside $redact.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_output.py b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_output.py new file mode 100644 index 000000000..aa5bc0c4e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_output.py @@ -0,0 +1,162 @@ +"""Tests for $redact output fidelity: value passthrough across BSON types and order preservation.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, OrderedKeys, PerDoc +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [Value Passthrough]: a kept field value is returned unchanged for +# every BSON type under $$KEEP. +REDACT_PASSTHROUGH_KEEP_TESTS: list[StageTestCase] = [ + StageTestCase( + f"passthrough_keep_{tid}", + docs=[{"_id": 1, "val": val}], + pipeline=[{"$redact": "$$KEEP"}], + expected=[{"_id": 1, "val": val}], + msg=f"$redact should return a stored {tid} value unchanged under $$KEEP", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 7), + ("int64", Int64(9)), + ("double", 3.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("document", {"a": 1, "b": {"c": 2}}), + ("array", [1, "x", {"y": 2}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", b"\x01\x02\x03"), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("null", None), + ] +] + +# Property [Value Passthrough]: a kept field value is returned unchanged for +# every BSON type under $$DESCEND. +REDACT_PASSTHROUGH_DESCEND_TESTS: list[StageTestCase] = [ + StageTestCase( + f"passthrough_descend_{tid}", + docs=[{"_id": 1, "val": val}], + pipeline=[{"$redact": "$$DESCEND"}], + expected=[{"_id": 1, "val": val}], + msg=f"$redact should return a stored {tid} value unchanged under $$DESCEND", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 7), + ("int64", Int64(9)), + ("double", 3.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("document", {"a": 1, "b": {"c": 2}}), + ("array", [1, "x", {"y": 2}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", b"\x01\x02\x03"), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("null", None), + ] +] + +# Property [Prune Across Types]: a document is removed under $$PRUNE regardless +# of the BSON type of a stored field. +REDACT_PRUNE_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + f"prune_{tid}", + docs=[{"_id": 1, "val": val}], + pipeline=[{"$redact": "$$PRUNE"}], + expected=[], + msg=f"$redact should drop a document holding a stored {tid} value under $$PRUNE", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 7), + ("int64", Int64(9)), + ("double", 3.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("document", {"a": 1, "b": {"c": 2}}), + ("array", [1, "x", {"y": 2}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", b"\x01\x02\x03"), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("null", None), + ] +] + +# Property [Order Preservation]: documents and the surviving fields within each +# document retain their original relative order. +REDACT_ORDER_TESTS: list[StageTestCase] = [ + StageTestCase( + "order_preserves_document_and_field_order", + docs=[ + {"_id": 3, "a": 1, "b": {"drop": True}, "c": 3}, + {"_id": 1, "x": {"drop": True}}, + {"_id": 2, "a": 1, "c": 3}, + ], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$drop", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=PerDoc( + {"_id": Eq(3), "": OrderedKeys(["_id", "a", "c"])}, + {"_id": Eq(1), "": OrderedKeys(["_id"])}, + {"_id": Eq(2), "": OrderedKeys(["_id", "a", "c"])}, + ), + msg="$redact should preserve input document order and surviving field order", + ), +] + +REDACT_OUTPUT_TESTS = ( + REDACT_PASSTHROUGH_KEEP_TESTS + + REDACT_PASSTHROUGH_DESCEND_TESTS + + REDACT_PRUNE_TYPE_TESTS + + REDACT_ORDER_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REDACT_OUTPUT_TESTS)) +def test_redact_output_cases(collection, test_case: StageTestCase): + """Test $redact value passthrough across BSON types and order preservation.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_scoping.py b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_scoping.py new file mode 100644 index 000000000..aae80b13d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_scoping.py @@ -0,0 +1,170 @@ +"""Tests for $redact per-level field environment and document-valued _id handling.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Per-Level Field Environment]: $field and $$CURRENT re-root to the +# current level on each re-evaluation while $$ROOT stays pinned to the original +# top-level document. +REDACT_PER_LEVEL_TESTS: list[StageTestCase] = [ + StageTestCase( + "perlevel_bare_field_rerooted", + docs=[{"_id": 1, "keep": True, "child": {"keep": False, "data": 1}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$keep", True]}, "$$DESCEND", "$$PRUNE"]}}], + expected=[{"_id": 1, "keep": True}], + msg="$redact should resolve a bare $field reference relative to the current " + "embedded level, not the top-level document", + ), + StageTestCase( + "perlevel_bare_dotted_path_rerooted", + docs=[ + {"_id": 1, "meta": {"score": 9}}, + {"_id": 2, "meta": {"score": 4}}, + ], + pipeline=[{"$redact": {"$cond": [{"$gte": ["$meta.score", 9]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "meta": {"score": 9}}], + msg="$redact should resolve a bare dotted-path field reference through an " + "embedded document at the current level when driving a sentinel", + ), + StageTestCase( + "perlevel_current_rerooted", + docs=[{"_id": 1, "keep": True, "child": {"keep": False, "data": 1}}], + pipeline=[ + {"$redact": {"$cond": [{"$eq": ["$$CURRENT.keep", True]}, "$$DESCEND", "$$PRUNE"]}} + ], + expected=[{"_id": 1, "keep": True}], + msg="$redact should rebind $$CURRENT to the current embedded level, matching a " + "bare $field reference", + ), + StageTestCase( + "perlevel_root_pinned_no_rebind", + docs=[{"_id": 1, "top": True, "child": {"top": False, "data": 1}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$$ROOT.top", True]}, "$$DESCEND", "$$PRUNE"]}}], + expected=[{"_id": 1, "top": True, "child": {"top": False, "data": 1}}], + msg="$redact should keep $$ROOT pinned to the top-level document and not rebind " + "it during descent", + ), + StageTestCase( + "perlevel_root_exposes_pruned_sibling", + docs=[{"_id": 1, "x": {"drop": True}, "y": {"data": 5}}], + pipeline=[ + { + "$redact": { + "$switch": { + "branches": [ + {"case": {"$eq": ["$drop", True]}, "then": "$$PRUNE"}, + { + "case": {"$eq": ["$data", 5]}, + "then": { + "$cond": [ + {"$eq": ["$$ROOT.x.drop", True]}, + "$$KEEP", + "$$PRUNE", + ] + }, + }, + ], + "default": "$$DESCEND", + } + } + } + ], + expected=[{"_id": 1, "y": {"data": 5}}], + msg="$redact should expose a sibling's original value through $$ROOT even after " + "that sibling was pruned earlier in the walk", + ), + StageTestCase( + "perlevel_field_present_root_absent_deeper", + docs=[{"_id": 1, "flag": True, "child": {"data": 1}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$flag", True]}, "$$DESCEND", "$$PRUNE"]}}], + expected=[{"_id": 1, "flag": True}], + msg="$redact should resolve a field absent at a deeper level to missing rather " + "than leaking the root's value downward", + ), + StageTestCase( + "perlevel_field_absent_root_present_deeper", + docs=[{"_id": 1, "child": {"deep": True, "data": 1}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$deep", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1}], + msg="$redact should resolve a field absent at the root to missing while binding " + "it to its value at the deeper level", + ), +] + +# Property [_id Field Handling]: a document-valued _id is treated as an ordinary +# embedded document under each sentinel and per-level field reference. +REDACT_ID_FIELD_TESTS: list[StageTestCase] = [ + StageTestCase( + "id_descend_empties_to_empty_document", + docs=[{"_id": {"k": {"secret": True}}, "top": 1}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": {}, "top": 1}], + msg="$redact under $$DESCEND should descend into a document-valued _id and " + "retain it emptied as an empty document", + ), + StageTestCase( + "id_keep_wholesale_discriminating", + docs=[{"_id": {"k": {"secret": True}}, "top": 1}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$KEEP"]}}], + expected=[{"_id": {"k": {"secret": True}}, "top": 1}], + msg="$redact under $$KEEP should keep a document-valued _id wholesale without " + "descending into it, leaving nested would-prune content intact", + ), + StageTestCase( + "id_descend_field_ref_rebinds", + docs=[{"_id": {"sub": {"drop": True}, "scalar": 9}, "top": 1}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$drop", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": {"scalar": 9}, "top": 1}], + msg="$redact under $$DESCEND should rebind a bare field reference to the _id " + "subdocument so a matching nested doc is pruned while a sibling scalar survives", + ), + StageTestCase( + "id_descend_current_rebinds", + docs=[{"_id": {"sub": {"drop": True}, "scalar": 9}, "top": 1}], + pipeline=[ + {"$redact": {"$cond": [{"$eq": ["$$CURRENT.drop", True]}, "$$PRUNE", "$$DESCEND"]}} + ], + expected=[{"_id": {"scalar": 9}, "top": 1}], + msg="$redact under $$DESCEND should rebind $$CURRENT to the _id subdocument so a " + "matching nested doc is pruned while a sibling scalar survives", + ), + StageTestCase( + "id_only_descend_retained", + docs=[{"_id": {"k": 1}}], + pipeline=[{"$redact": "$$DESCEND"}], + expected=[{"_id": {"k": 1}}], + msg="$redact under $$DESCEND should retain an _id-only document", + ), +] + +REDACT_SCOPING_TESTS = REDACT_PER_LEVEL_TESTS + REDACT_ID_FIELD_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REDACT_SCOPING_TESTS)) +def test_redact_scoping_cases(collection, test_case: StageTestCase): + """Test $redact per-level field environment and document-valued _id handling.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_sentinels.py b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_sentinels.py new file mode 100644 index 000000000..ad941bda9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_sentinels.py @@ -0,0 +1,203 @@ +"""Tests for $redact $$KEEP/$$PRUNE/$$DESCEND semantics.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [$$KEEP Semantics]: $$KEEP keeps the current level wholesale without +# descending into it. +REDACT_KEEP_TESTS: list[StageTestCase] = [ + StageTestCase( + "keep_no_descend_embedded_document", + docs=[{"_id": 1, "level": 1, "child": {"level": 2, "secret": True}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$level", 1]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "level": 1, "child": {"level": 2, "secret": True}}], + msg="$redact under $$KEEP should keep a nested document that would prune if descended", + ), + StageTestCase( + "keep_no_descend_array_element", + docs=[{"_id": 1, "level": 1, "items": [{"level": 2, "secret": True}]}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$level", 1]}, "$$KEEP", "$$PRUNE"]}}], + expected=[{"_id": 1, "level": 1, "items": [{"level": 2, "secret": True}]}], + msg="$redact under $$KEEP should keep an array element that would prune if descended", + ), + StageTestCase( + "keep_embedded_level_halts_descent", + docs=[ + { + "_id": 1, + "level": 1, + "child": {"level": 2, "grandchild": {"level": 3, "secret": True}}, + } + ], + pipeline=[ + { + "$redact": { + "$switch": { + "branches": [ + {"case": {"$eq": ["$level", 1]}, "then": "$$DESCEND"}, + {"case": {"$eq": ["$level", 2]}, "then": "$$KEEP"}, + ], + "default": "$$PRUNE", + } + } + } + ], + expected=[ + { + "_id": 1, + "level": 1, + "child": {"level": 2, "grandchild": {"level": 3, "secret": True}}, + } + ], + msg="$redact under $$KEEP at an embedded level should keep that level " + "wholesale and halt descent into a would-prune subtree", + ), +] + +# Property [$$PRUNE Semantics]: $$PRUNE removes the current level outright +# without descending, compacting a pruned array element with no placeholder. +REDACT_PRUNE_TESTS: list[StageTestCase] = [ + StageTestCase( + "prune_no_descend_into_subtree", + docs=[ + { + "_id": 1, + "level": 1, + "child": {"level": 2, "grandchild": {"level": 1, "keepme": 7}}, + } + ], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$level", 1]}, "$$DESCEND", "$$PRUNE"]}}], + expected=[{"_id": 1, "level": 1}], + msg="$redact under $$PRUNE should drop a subtree without descending into a " + "would-keep nested document", + ), + StageTestCase( + "prune_removes_embedded_document_field", + docs=[{"_id": 1, "level": 1, "child": {"secret": True}, "keepme": 7}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$level", 1]}, "$$DESCEND", "$$PRUNE"]}}], + expected=[{"_id": 1, "level": 1, "keepme": 7}], + msg="$redact under $$PRUNE should remove a pruned embedded-document field " + "entirely rather than retain an empty document", + ), + StageTestCase( + "prune_compacts_array_element", + docs=[ + { + "_id": 1, + "level": 1, + "items": [ + {"keep": 1, "level": 1}, + {"drop": 1, "level": 2}, + {"keep": 2, "level": 1}, + ], + } + ], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$level", 1]}, "$$DESCEND", "$$PRUNE"]}}], + expected=[ + {"_id": 1, "level": 1, "items": [{"keep": 1, "level": 1}, {"keep": 2, "level": 1}]} + ], + msg="$redact under $$PRUNE should remove a pruned array element and compact " + "the array with no placeholder", + ), +] + +# Property [$$DESCEND Semantics]: $$DESCEND keeps scalar fields and recurses +# into each embedded document, retaining a level emptied by deeper pruning. +REDACT_DESCEND_TESTS: list[StageTestCase] = [ + StageTestCase( + "descend_recurses_embedded_document", + docs=[{"_id": 1, "a": 1, "child": {"b": 2, "sub": {"secret": True}}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, "a": 1, "child": {"b": 2}}], + msg="$redact under $$DESCEND should keep scalar fields and recurse into an " + "embedded document rather than keep or prune it wholesale", + ), + StageTestCase( + "descend_recurses_embedded_document_in_array", + docs=[{"_id": 1, "a": 1, "items": [{"b": 2, "sub": {"secret": True}}, {"c": 3}]}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, "a": 1, "items": [{"b": 2}, {"c": 3}]}], + msg="$redact under $$DESCEND should recurse into embedded documents nested " + "inside an array", + ), + StageTestCase( + "descend_keeps_non_document_array_elements", + docs=[{"_id": 1, "items": [1, "x", [2, 3], {"secret": True}, {"keep": 9}]}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, "items": [1, "x", [2, 3], {"keep": 9}]}], + msg="$redact under $$DESCEND should keep non-document array elements as-is " + "while evaluating document elements", + ), + StageTestCase( + "descend_array_of_arrays_with_documents", + docs=[{"_id": 1, "matrix": [[{"keep": 1}, {"secret": True}], [{"keep": 2}]]}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, "matrix": [[{"keep": 1}], [{"keep": 2}]]}], + msg="$redact under $$DESCEND should descend into a nested array of arrays and " + "evaluate each embedded document independently", + ), + StageTestCase( + "descend_prune_all_array_elements_retains_empty_array", + docs=[{"_id": 1, "items": [{"secret": True}, {"secret": True}]}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, "items": []}], + msg="$redact under $$DESCEND should retain an array emptied by pruning every " + "document element as an empty array", + ), + StageTestCase( + "descend_empty_embedded_document_retained", + docs=[{"_id": 1, "child": {"sub": {"secret": True}}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, "child": {}}], + msg="$redact under $$DESCEND should retain an embedded document emptied by " + "deeper pruning as an empty document", + ), + StageTestCase( + "descend_multilevel_intermediate_empty_retained", + docs=[{"_id": 1, "a": {"b": {"c": {"secret": True}}}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, "a": {"b": {}}}], + msg="$redact under $$DESCEND should retain an intermediate document emptied by " + "deeper pruning without propagating emptiness upward", + ), + StageTestCase( + "descend_array_element_empty_retained_not_compacted", + docs=[{"_id": 1, "items": [{"keep": 1}, {"sub": {"secret": True}}, {"keep": 2}]}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$secret", True]}, "$$PRUNE", "$$DESCEND"]}}], + expected=[{"_id": 1, "items": [{"keep": 1}, {}, {"keep": 2}]}], + msg="$redact under $$DESCEND should retain a document array element emptied by " + "deeper pruning as an empty document without compacting the array", + ), +] + +REDACT_SENTINEL_TESTS = REDACT_KEEP_TESTS + REDACT_PRUNE_TESTS + REDACT_DESCEND_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REDACT_SENTINEL_TESTS)) +def test_redact_sentinel_cases(collection, test_case: StageTestCase): + """Test $redact sentinel resolution and $$KEEP/$$PRUNE/$$DESCEND semantics.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_validation.py b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_validation.py new file mode 100644 index 000000000..95715f1c3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/redact/test_redact_validation.py @@ -0,0 +1,314 @@ +"""Tests for $redact argument validation: lazy evaluation and non-sentinel rejection.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + REDACT_NON_SENTINEL_ERROR, + SIZE_NOT_ARRAY_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_ONE_AND_HALF, MISSING + +# Property [Lazy Evaluation]: only the value the expression actually resolves to +# is validated, so unreached invalid values produce no error. +REDACT_LAZY_TESTS: list[StageTestCase] = [ + StageTestCase( + "lazy_cond_else_invalid_untaken", + docs=[{"_id": 1, "level": 5, "other": "x"}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$level", 5]}, "$$KEEP", "oops"]}}], + expected=[{"_id": 1, "level": 5, "other": "x"}], + msg="$redact should not validate an invalid value in an untaken $cond else branch", + ), + StageTestCase( + "lazy_cond_then_invalid_untaken", + docs=[{"_id": 1, "level": 3}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$level", 5]}, "oops", "$$PRUNE"]}}], + expected=[], + msg="$redact should not validate an invalid value in an untaken $cond then branch", + ), + StageTestCase( + "lazy_keep_short_circuits_descent", + docs=[{"_id": 1, "keep": True, "child": {"keep": False, "bad": 1}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$keep", True]}, "$$KEEP", "$bad"]}}], + expected=[{"_id": 1, "keep": True, "child": {"keep": False, "bad": 1}}], + msg="$redact under $$KEEP should not evaluate the expression at a deeper level " + "where it would resolve to a non-sentinel value", + ), + StageTestCase( + "lazy_prune_short_circuits_descent", + docs=[{"_id": 1, "drop": True, "child": {"drop": False, "bad": 1}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$drop", True]}, "$$PRUNE", "$bad"]}}], + expected=[], + msg="$redact under $$PRUNE should not evaluate the expression at a deeper level " + "where it would resolve to a non-sentinel value", + ), + StageTestCase( + "lazy_empty_collection_not_evaluated", + docs=[], + pipeline=[{"$redact": {"$literal": "not_a_sentinel"}}], + expected=[], + msg="$redact should not evaluate a non-sentinel expression against an empty " + "collection, returning no documents and no error", + ), +] + +# Property [Null and Missing Argument]: an argument resolving to null or missing +# is rejected as a non-sentinel at evaluation time. +REDACT_NULL_MISSING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "null_bare_argument", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": None}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a bare null argument as a non-sentinel value", + ), + StageTestCase( + "null_missing_field_reference", + docs=[{"_id": 1, "a": 1}], + pipeline=[{"$redact": MISSING}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a missing-field reference that resolves to missing", + ), +] + +# Property [Non-Sentinel Type Strictness]: a resolved value of any non-sentinel +# BSON type is rejected. +REDACT_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"type_{tid}", + docs=[{"_id": 1}], + pipeline=[{"$redact": {"$literal": val}}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg=f"$redact should reject a resolved {tid} value as a non-sentinel", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 7), + ("int64", Int64(9)), + ("double", 3.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("document", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Sentinel Name Strings]: a string equal to a sentinel name or the +# literal text "$$DESCEND" resolves to a string and is rejected. +REDACT_SENTINEL_STRING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "sentinel_string_descend", + docs=[{"_id": 1}], + pipeline=[{"$redact": "DESCEND"}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject the bare string 'DESCEND' as a non-sentinel", + ), + StageTestCase( + "sentinel_string_prune", + docs=[{"_id": 1}], + pipeline=[{"$redact": "PRUNE"}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject the bare string 'PRUNE' as a non-sentinel", + ), + StageTestCase( + "sentinel_string_keep", + docs=[{"_id": 1}], + pipeline=[{"$redact": "KEEP"}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject the bare string 'KEEP' as a non-sentinel", + ), + StageTestCase( + "sentinel_string_literal_dollar_descend", + docs=[{"_id": 1}], + pipeline=[{"$redact": {"$literal": "$$DESCEND"}}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject the literal string '$$DESCEND' as a non-sentinel", + ), + StageTestCase( + "sentinel_string_stored_field", + docs=[{"_id": 1, "sname": "$$DESCEND"}], + pipeline=[{"$redact": "$sname"}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a stored field whose value is the string '$$DESCEND'", + ), + StageTestCase( + "sentinel_string_concat_literals", + docs=[{"_id": 1}], + pipeline=[{"$redact": {"$concat": [{"$literal": "$$"}, "PRUNE"]}}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a string equal to '$$PRUNE' assembled by $concat from " + "literal parts, not treat it as the sentinel", + ), + StageTestCase( + "sentinel_string_concat_fields", + docs=[{"_id": 1, "prefix": "$$", "name": "PRUNE"}], + pipeline=[{"$redact": {"$concat": ["$prefix", "$name"]}}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a string equal to '$$PRUNE' assembled by $concat from " + "field references, not treat it as the sentinel", + ), +] + +# Property [Defined Non-Sentinel System Variables]: a defined non-sentinel +# system variable resolves to a non-sentinel value and is rejected. +REDACT_DEFINED_VARIABLE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"defined_variable_{label}", + docs=[{"_id": 1}], + pipeline=[{"$redact": var}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg=f"$redact should reject the defined system variable {var} as a non-sentinel", + ) + for label, var in [ + ("root", "$$ROOT"), + ("current", "$$CURRENT"), + ("remove", "$$REMOVE"), + ("now", "$$NOW"), + ] +] + +# Property [Argument Shape Strictness]: the stage does not unwrap array +# arguments or special-case empty containers, so each resolves to a +# non-sentinel value and is rejected. +REDACT_ARGUMENT_SHAPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "shape_array_single_sentinel", + docs=[{"_id": 1}], + pipeline=[{"$redact": ["$$DESCEND"]}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a single-element literal array without unwrapping its " + "sentinel element", + ), + StageTestCase( + "shape_array_single_expression", + docs=[{"_id": 1}], + pipeline=[{"$redact": [{"$cond": [{"$eq": [1, 1]}, "$$KEEP", "$$PRUNE"]}]}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a single-element array wrapping a sentinel-returning " + "expression without unwrapping it", + ), + StageTestCase( + "shape_array_nested_deeper", + docs=[{"_id": 1}], + pipeline=[{"$redact": [["$$DESCEND"]]}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a deeper nested array without unwrapping at any depth", + ), + StageTestCase( + "shape_array_multi_element", + docs=[{"_id": 1}], + pipeline=[{"$redact": ["$$DESCEND", "$$KEEP"]}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a multi-element array as a single array value rather " + "than treating it as an arity error", + ), + StageTestCase( + "shape_array_field_reference", + docs=[{"_id": 1, "arr": ["DESCEND"]}], + pipeline=[{"$redact": "$arr"}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject a field reference whose stored value is an array " + "identically to a literal array", + ), + StageTestCase( + "shape_empty_document", + docs=[{"_id": 1}], + pipeline=[{"$redact": {}}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject an empty-document argument as a non-sentinel value", + ), + StageTestCase( + "shape_empty_array", + docs=[{"_id": 1}], + pipeline=[{"$redact": []}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact should reject an empty-array argument as a non-sentinel value", + ), +] + +# Property [Inner Expression Errors During Descent]: an inner operator error on +# a value reached at a deeper level surfaces rather than a redact error. +REDACT_INNER_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "inner_size_setintersection_missing_at_depth", + docs=[{"_id": 1, "tags": ["A"], "sub": {"content": "x"}}], + pipeline=[ + { + "$redact": { + "$cond": { + "if": {"$gt": [{"$size": {"$setIntersection": ["$tags", ["A"]]}}, 0]}, + "then": "$$DESCEND", + "else": "$$PRUNE", + } + } + } + ], + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$redact should surface the inner $size error when $setIntersection of a " + "field absent at a deeper level yields null during descent", + ), +] + +# Property [Non-Sentinel Reached via $$DESCEND]: a non-sentinel resolution +# reachable only by descending is reached and rejected at the deeper level. +REDACT_DESCEND_REACHES_NON_SENTINEL_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "descend_reaches_deep_non_sentinel", + docs=[{"_id": 1, "keep": True, "child": {"keep": False, "bad": 1}}], + pipeline=[{"$redact": {"$cond": [{"$eq": ["$keep", True]}, "$$DESCEND", "$bad"]}}], + error_code=REDACT_NON_SENTINEL_ERROR, + msg="$redact under $$DESCEND should reach and reject a non-sentinel value at a " + "deeper level that a $$KEEP/$$PRUNE short-circuit would never evaluate", + ), +] + +REDACT_VALIDATION_TESTS = ( + REDACT_LAZY_TESTS + + REDACT_NULL_MISSING_ERROR_TESTS + + REDACT_TYPE_ERROR_TESTS + + REDACT_SENTINEL_STRING_ERROR_TESTS + + REDACT_DEFINED_VARIABLE_ERROR_TESTS + + REDACT_ARGUMENT_SHAPE_ERROR_TESTS + + REDACT_INNER_ERROR_TESTS + + REDACT_DESCEND_REACHES_NON_SENTINEL_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REDACT_VALIDATION_TESTS)) +def test_redact_validation_cases(collection, test_case: StageTestCase): + """Test $redact lazy evaluation and rejection of non-sentinel argument values.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_redact.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_redact.py new file mode 100644 index 000000000..2e0f58d44 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_redact.py @@ -0,0 +1,129 @@ +"""Tests for $redact composing with other stages at different pipeline positions.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Pipeline Position]: $redact composes correctly with preceding stages +# that reshape documents and with following stages that consume its output. +REDACT_PIPELINE_POSITION_TESTS: list[StageTestCase] = [ + StageTestCase( + "pipeline_after_computed_field", + docs=[{"_id": 1, "level": 2}, {"_id": 2, "level": 8}], + pipeline=[ + {"$set": {"allowed": {"$lt": ["$level", 5]}}}, + {"$redact": {"$cond": [{"$eq": ["$allowed", True]}, "$$KEEP", "$$PRUNE"]}}, + ], + expected=[{"_id": 1, "level": 2, "allowed": True}], + msg="$redact should evaluate its condition against a field computed by a preceding stage", + ), + StageTestCase( + "pipeline_before_match_on_pruned_field", + docs=[ + {"_id": 1, "detail": {"restricted": False, "flag": True}}, + {"_id": 2, "detail": {"restricted": True, "flag": True}}, + ], + pipeline=[ + {"$redact": {"$cond": [{"$eq": ["$restricted", True]}, "$$PRUNE", "$$DESCEND"]}}, + {"$match": {"detail.flag": True}}, + ], + expected=[{"_id": 1, "detail": {"restricted": False, "flag": True}}], + msg="a following $match should filter on the redacted output, excluding a document " + "whose matchable field $redact pruned while keeping one whose field survived", + ), + StageTestCase( + "pipeline_before_count_of_survivors", + docs=[ + {"_id": 1, "status": "public"}, + {"_id": 2, "status": "secret"}, + {"_id": 3, "status": "public"}, + ], + pipeline=[ + {"$redact": {"$cond": [{"$eq": ["$status", "public"]}, "$$KEEP", "$$PRUNE"]}}, + {"$count": "n"}, + ], + expected=[{"n": 2}], + msg="a following $count should count only the documents $redact kept", + ), + StageTestCase( + "pipeline_middle_stage", + docs=[ + {"_id": 1, "status": "public", "a": 1}, + {"_id": 2, "status": "secret", "a": 3}, + {"_id": 3, "status": "public", "a": 5}, + ], + pipeline=[ + {"$match": {"a": {"$gt": 0}}}, + {"$redact": {"$cond": [{"$eq": ["$status", "public"]}, "$$KEEP", "$$PRUNE"]}}, + {"$project": {"a": 1}}, + ], + expected=[{"_id": 1, "a": 1}, {"_id": 3, "a": 5}], + msg="$redact should work as a middle stage between a preceding and a following stage", + ), + StageTestCase( + "pipeline_last_stage", + docs=[ + {"_id": 1, "status": "public", "a": 1}, + {"_id": 2, "status": "secret", "a": 2}, + ], + pipeline=[ + {"$project": {"status": 1, "a": 1}}, + {"$redact": {"$cond": [{"$eq": ["$status", "public"]}, "$$KEEP", "$$PRUNE"]}}, + ], + expected=[{"_id": 1, "status": "public", "a": 1}], + msg="$redact should work as the last stage of a pipeline", + ), + StageTestCase( + "pipeline_after_unwind", + docs=[{"_id": 1, "items": [{"status": "public", "v": 1}, {"status": "secret", "v": 2}]}], + pipeline=[ + {"$unwind": "$items"}, + {"$redact": {"$cond": [{"$eq": ["$items.status", "public"]}, "$$KEEP", "$$PRUNE"]}}, + ], + expected=[{"_id": 1, "items": {"status": "public", "v": 1}}], + msg="$redact should evaluate each document produced by a preceding $unwind independently", + ), + StageTestCase( + "pipeline_after_root_replacement", + docs=[ + {"_id": 1, "inner": {"status": "public", "x": 1}}, + {"_id": 2, "inner": {"status": "secret", "x": 2}}, + ], + pipeline=[ + {"$replaceRoot": {"newRoot": "$inner"}}, + {"$redact": {"$cond": [{"$eq": ["$status", "public"]}, "$$KEEP", "$$PRUNE"]}}, + ], + expected=[{"status": "public", "x": 1}], + msg="$redact should evaluate its condition against the shape produced by a " + "root replacement stage", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(REDACT_PIPELINE_POSITION_TESTS)) +def test_stage_position_redact_cases(collection, test_case: StageTestCase): + """Test $redact composing with other stages at different pipeline positions.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index aa27399c7..423b3fe65 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -134,6 +134,7 @@ SET_INTERSECTION_NON_ARRAY_ERROR = 17047 SET_DIFFERENCE_FIRST_NOT_ARRAY_ERROR = 17048 SET_DIFFERENCE_SECOND_NOT_ARRAY_ERROR = 17049 +REDACT_NON_SENTINEL_ERROR = 17053 COND_MISSING_IF_ERROR = 17080 COND_MISSING_THEN_ERROR = 17081 COND_MISSING_ELSE_ERROR = 17082 From f6b33076e5bcaebbd9962e1dd268e62fc9bd6366 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 24 Jun 2026 18:34:43 -0700 Subject: [PATCH 10/51] Add currentOp stage tests (#592) Signed-off-by: Daniel Frankcom --- .../test_system_stage_currentOp_live_state.py | 510 ++++++++++++++++++ .../test_system_stage_currentOp_operand.py | 345 ++++++++++++ .../test_system_stage_currentOp_usage.py | 103 ++++ documentdb_tests/framework/executor.py | 5 +- 4 files changed, 961 insertions(+), 2 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_live_state.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_operand.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_usage.py diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_live_state.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_live_state.py new file mode 100644 index 000000000..5242bac53 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_live_state.py @@ -0,0 +1,510 @@ +"""Tests for the $currentOp aggregation pipeline stage against live operation state.""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, Gte, NotExists + + +@dataclass(frozen=True) +class LiveState: + """Default. No additional concurrent operation is in flight.""" + + @contextmanager + def hold(self, collection) -> Iterator[None]: + """Establish the concurrent state, run the body, then tear it down.""" + yield + + @staticmethod + def _wait_for_op(collection, op: str, timeout_seconds: float = 10.0) -> None: + """Poll currentOp until an operation of the given op type is in flight. + + Returns as soon as the op is observable so the caller can yield while it + is still running. Raises AssertionError if the op never appears within + timeout_seconds, so the failure is reported at its root cause rather + than as a confusing downstream assertion failure. + """ + deadline = time.monotonic() + timeout_seconds + poll_interval_seconds = 0.05 + while time.monotonic() < deadline: + ops = collection.database.client.admin.command( + {"currentOp": True, "op": op, "$all": True} + ) + if ops["inprog"]: + return + time.sleep(poll_interval_seconds) + raise AssertionError( + f"operation {op!r} did not appear in currentOp within {timeout_seconds}s" + ) + + +@dataclass(frozen=True) +class IdleCursor(LiveState): + """An idle find cursor left open with documents still unfetched.""" + + @contextmanager + def hold(self, collection) -> Iterator[None]: + collection.insert_many([{"x": i} for i in range(5)]) + cursor_id = execute_command(collection, {"find": collection.name, "batchSize": 2})[ + "cursor" + ]["id"] + try: + yield + finally: + execute_command(collection, {"killCursors": collection.name, "cursors": [cursor_id]}) + + +@dataclass(frozen=True) +class LockHoldingTransaction(LiveState): + """An open multi-document transaction holding a write lock.""" + + @contextmanager + def hold(self, collection) -> Iterator[None]: + collection.insert_one({"_id": 1, "v": 0}) + session = collection.database.client.start_session() + session.start_transaction() + try: + collection.update_one({"_id": 1}, {"$set": {"v": 1}}, session=session) + yield + finally: + session.abort_transaction() + session.end_session() + + +@dataclass(frozen=True) +class ActiveGetMore(LiveState): + """An active getMore: a find cursor iterated with a per-document sleep so the + getMore is still executing when $currentOp observes it.""" + + @contextmanager + def hold(self, collection) -> Iterator[None]: + collection.insert_many([{"v": i} for i in range(10)]) + + def run() -> None: + try: + cursor = collection.find({"$where": "sleep(200) || true"}, batch_size=1) + for _ in cursor: + pass + except Exception: + pass + + thread = threading.Thread(target=run, daemon=True) + thread.start() + try: + # Wait until the find has issued its first getMore so $currentOp can + # observe it, rather than relying on a fixed delay. + self._wait_for_op(collection, "getmore") + yield + finally: + thread.join(timeout=10) + + +@dataclass(frozen=True) +class InFlightLargeCommand(LiveState): + """An in-flight find whose command document exceeds 1 KB, held briefly by a + single-document $where sleep so $currentOp observes its command. + + When comment is set it is added to the find command, which is carried out of + a truncated command as a sibling key. + """ + + comment: str | None = None + + @contextmanager + def hold(self, collection) -> Iterator[None]: + collection.insert_one({"_id": 1, "v": 0}) + # Inflate the command well past 1 KB without scanning more documents or + # lengthening the sleep: the predicate runs once on the single document. + large_predicate = "sleep(1800) || true" + (" || true" * 300) + comment = self.comment + + def run() -> None: + try: + kwargs: dict = {} + if comment is not None: + kwargs["comment"] = comment + list(collection.find({"$where": large_predicate}, **kwargs)) + except Exception: + pass + + thread = threading.Thread(target=run, daemon=True) + thread.start() + try: + # Wait until the in-flight find command is observable to $currentOp + # instead of relying on a fixed delay. + self._wait_for_op(collection, "query") + yield + finally: + thread.join(timeout=10) + + +@dataclass(frozen=True) +class CurrentOpLiveCase(StageTestCase): + """Test case for a $currentOp pipeline observed against live operation state.""" + + live_state: LiveState = field(default_factory=LiveState) + + +# Property [idleConnections Flag]: idleConnections:true reports inactive +# connections as op documents with active:false, while idleConnections:false +# suppresses every inactive document. +CURRENTOP_IDLE_CONNECTIONS_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "idle_connections_true_reports_inactive", + pipeline=[ + {"$currentOp": {"idleConnections": True}}, + {"$match": {"type": "op", "active": False}}, + {"$count": "n"}, + ], + expected={"n": Gte(1)}, + msg="$currentOp should report inactive operations when idleConnections is true", + ), + CurrentOpLiveCase( + "idle_connections_false_suppresses_inactive", + pipeline=[ + {"$currentOp": {"idleConnections": False}}, + {"$match": {"active": False}}, + {"$count": "n"}, + ], + expected=[], + msg="$currentOp should suppress inactive operations when idleConnections is false", + marks=(pytest.mark.no_parallel,), + ), +] + +# Property [idleCursors Flag]: idleCursors:true reports idle cursors as type +# "idleCursor" documents, while the default or false emits none. +CURRENTOP_IDLE_CURSORS_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "idle_cursors_true_reports_cursor", + live_state=IdleCursor(), + pipeline=[ + {"$currentOp": {"idleCursors": True, "idleConnections": False}}, + {"$match": {"type": "idleCursor"}}, + {"$count": "n"}, + ], + expected={"n": Gte(1)}, + msg="$currentOp should report idle cursors when idleCursors is true", + ), + CurrentOpLiveCase( + "idle_cursors_false_suppresses_cursor", + live_state=IdleCursor(), + pipeline=[ + {"$currentOp": {"idleCursors": False, "idleConnections": False}}, + {"$match": {"type": "idleCursor"}}, + {"$count": "n"}, + ], + expected=[], + msg="$currentOp should emit no idle cursors when idleCursors is false", + marks=(pytest.mark.no_parallel,), + ), +] + +# Property [idleSessions Flag]: an inactive lock-holding session is reported as +# a type "idleSession" document when idleSessions is true or omitted (the +# documented default is true), and suppressed when it is false. +CURRENTOP_IDLE_SESSIONS_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "idle_sessions_true_reports_session", + live_state=LockHoldingTransaction(), + pipeline=[ + {"$currentOp": {"idleSessions": True, "idleConnections": False}}, + {"$match": {"type": "idleSession"}}, + {"$count": "n"}, + ], + expected={"n": Gte(1)}, + msg="$currentOp should report a lock-holding session when idleSessions is true", + marks=(pytest.mark.requires(transactions=True),), + ), + CurrentOpLiveCase( + "idle_sessions_default_reports_session", + live_state=LockHoldingTransaction(), + pipeline=[ + {"$currentOp": {"idleConnections": False}}, + {"$match": {"type": "idleSession"}}, + {"$count": "n"}, + ], + expected={"n": Gte(1)}, + msg="$currentOp should report a lock-holding session when idleSessions is omitted", + marks=(pytest.mark.requires(transactions=True),), + ), + CurrentOpLiveCase( + "idle_sessions_false_suppresses_session", + live_state=LockHoldingTransaction(), + pipeline=[ + {"$currentOp": {"idleSessions": False, "idleConnections": False}}, + {"$match": {"type": "idleSession"}}, + {"$count": "n"}, + ], + expected=[], + msg="$currentOp should suppress a lock-holding session when idleSessions is false", + marks=(pytest.mark.requires(transactions=True), pytest.mark.no_parallel), + ), +] + +# Property [idleSessions Without Transaction]: no idleSession documents appear +# when no lock-holding transaction is open. +CURRENTOP_IDLE_SESSIONS_ABSENT_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "idle_sessions_absent_without_transaction", + pipeline=[ + {"$currentOp": {"idleSessions": True, "idleConnections": False}}, + {"$match": {"type": "idleSession"}}, + {"$count": "n"}, + ], + expected=[], + msg="$currentOp should report no idle sessions without an open lock-holding transaction", + marks=(pytest.mark.no_parallel,), + ), +] + +# Property [Emitted Document Type Set]: every emitted item has a type that is +# one of "op", "idleSession", or "idleCursor". +CURRENTOP_EMITTED_TYPE_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "emitted_document_types", + live_state=LockHoldingTransaction(), + pipeline=[ + {"$currentOp": {"idleConnections": True, "idleCursors": True, "idleSessions": True}}, + {"$match": {"type": {"$nin": ["op", "idleSession", "idleCursor"]}}}, + {"$count": "n"}, + ], + expected=[], + msg="$currentOp should emit only documents whose type is op, idleSession, or idleCursor", + marks=(pytest.mark.requires(transactions=True), pytest.mark.no_parallel), + ), +] + +# Property [Emitted Document Shard Field]: neither shard nor client_s appears on +# any emitted document on a non-sharded deployment. +CURRENTOP_EMITTED_SHARD_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "emitted_document_no_shard_fields", + pipeline=[ + {"$currentOp": {"idleConnections": True}}, + {"$match": {"$or": [{"shard": {"$exists": True}}, {"client_s": {"$exists": True}}]}}, + {"$count": "n"}, + ], + expected=[], + msg="$currentOp should omit shard and client_s fields on a non-sharded deployment", + marks=(pytest.mark.no_parallel,), + ), +] + +# Property [idleCursor Document Fields]: an idleCursor document carries the +# cursor sub-document but omits op, cursor.operationUsingCursorId, +# cursor.planSummary, transaction, client, and secs_running. +CURRENTOP_IDLE_CURSOR_FIELD_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "idle_cursor_document_fields", + live_state=IdleCursor(), + pipeline=[ + {"$currentOp": {"idleCursors": True, "idleConnections": False}}, + {"$match": {"type": "idleCursor"}}, + {"$limit": 1}, + ], + expected={ + "type": Eq("idleCursor"), + "cursor": Exists(), + "op": NotExists(), + "cursor.operationUsingCursorId": NotExists(), + "cursor.planSummary": NotExists(), + "transaction": NotExists(), + "client": NotExists(), + "secs_running": NotExists(), + }, + msg="$currentOp idleCursor documents should omit the active-op and client-op fields", + ), +] + +# Property [idleSession Document Fields]: the idleSession document of an open +# lock-holding transaction carries the transaction sub-document and lsid but +# omits op and secs_running. +CURRENTOP_IDLE_SESSION_FIELD_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "idle_session_document_fields", + live_state=LockHoldingTransaction(), + pipeline=[ + {"$currentOp": {"idleSessions": True, "idleConnections": True}}, + {"$match": {"type": "idleSession"}}, + {"$limit": 1}, + ], + expected={ + "type": Eq("idleSession"), + "transaction": Exists(), + "lsid": Exists(), + "op": NotExists(), + "secs_running": NotExists(), + }, + msg="$currentOp idleSession documents should carry the transaction sub-document and lsid", + marks=(pytest.mark.requires(transactions=True),), + ), +] + +# Property [Inactive op Document Fields]: an inactive type "op" document omits +# the running-time fields (secs_running, microsecs_running) and the transaction +# sub-document. +CURRENTOP_INACTIVE_OP_FIELD_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "inactive_op_document_fields", + pipeline=[ + {"$currentOp": {"idleConnections": True}}, + {"$match": {"type": "op", "active": False}}, + {"$limit": 1}, + ], + expected={ + "type": Eq("op"), + "active": Eq(False), + "secs_running": NotExists(), + "microsecs_running": NotExists(), + "transaction": NotExists(), + }, + msg="$currentOp inactive op documents should omit running-time and transaction fields", + ), +] + +# Property [Active getMore Document Fields]: an active getMore is a type "op" +# document with op "getmore" that carries the running-time fields, the cursor +# sub-document, and cursor.operationUsingCursorId, while omitting the +# transaction sub-document. +CURRENTOP_ACTIVE_GETMORE_FIELD_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "active_getmore_document_fields", + live_state=ActiveGetMore(), + pipeline=[ + {"$currentOp": {"allUsers": True, "idleConnections": True}}, + {"$match": {"op": "getmore"}}, + {"$limit": 1}, + ], + expected={ + "type": Eq("op"), + "op": Eq("getmore"), + "active": Eq(True), + "secs_running": Exists(), + "microsecs_running": Exists(), + "cursor": Exists(), + "cursor.operationUsingCursorId": Exists(), + "transaction": NotExists(), + }, + msg="$currentOp active getMore documents should carry running-time and cursor fields", + ), +] + +# Property [Command Not Truncated]: the command document is emitted in full +# (no $truncated key) when truncateOps is omitted or false regardless of the +# command size, and when truncateOps is true but the command is at or under the +# size threshold. +CURRENTOP_FULL_COMMAND_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "command_full_truncateOps_omitted_large", + live_state=InFlightLargeCommand(), + pipeline=[ + {"$currentOp": {"allUsers": True, "idleConnections": True}}, + {"$match": {"command.find": {"$exists": True}}}, + {"$limit": 1}, + ], + expected={ + "command.find": Exists(), + "command.$truncated": NotExists(), + }, + msg="$currentOp should emit a large command in full when truncateOps is omitted", + ), + CurrentOpLiveCase( + "command_full_truncateOps_false_large", + live_state=InFlightLargeCommand(), + pipeline=[ + {"$currentOp": {"truncateOps": False, "allUsers": True, "idleConnections": True}}, + {"$match": {"command.find": {"$exists": True}}}, + {"$limit": 1}, + ], + expected={ + "command.find": Exists(), + "command.$truncated": NotExists(), + }, + msg="$currentOp should emit a large command in full when truncateOps is false", + ), +] + +# Property [Command Truncated]: with truncateOps true and a command larger than +# the size threshold, the command document is replaced by a single $truncated +# string key. +CURRENTOP_TRUNCATED_COMMAND_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "command_truncated_large", + live_state=InFlightLargeCommand(), + pipeline=[ + {"$currentOp": {"truncateOps": True, "allUsers": True, "idleConnections": True}}, + {"$match": {"command.$truncated": {"$exists": True}}}, + {"$limit": 1}, + ], + expected={ + "command.$truncated": Exists(), + "command.find": NotExists(), + "command.comment": NotExists(), + }, + msg="$currentOp should replace a large command with a single $truncated key", + marks=(pytest.mark.no_parallel,), + ), +] + +# Property [Truncated Command Comment Sibling]: with truncateOps true and a +# command larger than the size threshold, a comment is carried out of the +# truncated command as a sibling key alongside $truncated. +CURRENTOP_TRUNCATED_COMMENT_TESTS: list[CurrentOpLiveCase] = [ + CurrentOpLiveCase( + "command_truncated_comment_sibling", + live_state=InFlightLargeCommand(comment="currentOp_truncation_marker"), + pipeline=[ + {"$currentOp": {"truncateOps": True, "allUsers": True, "idleConnections": True}}, + {"$match": {"command.$truncated": {"$exists": True}}}, + {"$limit": 1}, + ], + expected={ + "command.$truncated": Exists(), + "command.comment": Eq("currentOp_truncation_marker"), + }, + msg="$currentOp should carry the comment as a sibling key of a truncated command", + marks=(pytest.mark.no_parallel,), + ), +] + +CURRENTOP_LIVE_TESTS: list[CurrentOpLiveCase] = ( + CURRENTOP_IDLE_CONNECTIONS_TESTS + + CURRENTOP_IDLE_CURSORS_TESTS + + CURRENTOP_IDLE_SESSIONS_TESTS + + CURRENTOP_IDLE_SESSIONS_ABSENT_TESTS + + CURRENTOP_EMITTED_TYPE_TESTS + + CURRENTOP_EMITTED_SHARD_TESTS + + CURRENTOP_IDLE_CURSOR_FIELD_TESTS + + CURRENTOP_IDLE_SESSION_FIELD_TESTS + + CURRENTOP_INACTIVE_OP_FIELD_TESTS + + CURRENTOP_ACTIVE_GETMORE_FIELD_TESTS + + CURRENTOP_FULL_COMMAND_TESTS + + CURRENTOP_TRUNCATED_COMMAND_TESTS + + CURRENTOP_TRUNCATED_COMMENT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CURRENTOP_LIVE_TESTS)) +def test_currentOp_live_state(collection, test_case: CurrentOpLiveCase): + """Test $currentOp reporting against an in-flight operation or session.""" + with test_case.live_state.hold(collection): + result = execute_admin_command( + collection, + {"aggregate": 1, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_operand.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_operand.py new file mode 100644 index 000000000..016c3e2db --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_operand.py @@ -0,0 +1,345 @@ +"""Tests for $currentOp operand validation, type coercion, and error behavior.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [Operand Type Errors]: an operand that is not a BSON object produces +# a TypeMismatch error, and a literal array operand is never unwrapped into an +# argument list (it is rejected like any other non-object type). +CURRENTOP_OPERAND_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"operand_type_{tid}", + pipeline=[{"$currentOp": value}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$currentOp should reject a {tid} operand", + ) + for tid, value in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array_empty", []), + ("array_string", ["a"]), + ("array_options_doc", [{"allUsers": True}]), + ] +] + +# Property [Field Type Errors]: each documented boolean field and the +# undocumented truncateOps field rejects every non-boolean BSON type with a +# TypeMismatch error. +CURRENTOP_FIELD_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"field_type_{field_name}_{tid}", + pipeline=[{"$currentOp": {field_name: value}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$currentOp should reject a {tid} value for the {field_name} field", + ) + for field_name in ( + "allUsers", + "idleConnections", + "idleCursors", + "idleSessions", + "localOps", + "targetAllNodes", + "truncateOps", + ) + for tid, value in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("string", "x"), + ("array", [1]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Field Numeric Non-Coercion]: no numeric field value is coerced to a +# boolean; integer zero/one, whole-number and signed-zero doubles, NaN, and +# infinities are all rejected. +CURRENTOP_FIELD_NUMERIC_COERCION_TESTS: list[StageTestCase] = [ + StageTestCase( + f"field_numeric_{field_name}_{nid}", + pipeline=[{"$currentOp": {field_name: value}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$currentOp should not coerce numeric {nid} to a boolean for the {field_name} field", + ) + for field_name in ("allUsers", "idleConnections") + for nid, value in [ + ("int_zero", 0), + ("double_one", 1.0), + ("double_zero", DOUBLE_ZERO), + ("nan", FLOAT_NAN), + ("inf", FLOAT_INFINITY), + ] +] + +# Property [Field String Non-Coercion]: no string field value is coerced to a +# boolean; "true", "false", and the empty string are all rejected. +CURRENTOP_FIELD_STRING_COERCION_TESTS: list[StageTestCase] = [ + StageTestCase( + f"field_string_{field_name}_{sid}", + pipeline=[{"$currentOp": {field_name: value}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$currentOp should not coerce string {sid} to a boolean for the {field_name} field", + ) + for field_name in ("allUsers", "idleConnections") + for sid, value in [ + ("true", "true"), + ("false", "false"), + ("empty", ""), + ] +] + +# Property [Field Array Non-Unwrapping]: an array field value is rejected and +# never unwrapped into a boolean, regardless of its contents. +CURRENTOP_FIELD_ARRAY_COERCION_TESTS: list[StageTestCase] = [ + StageTestCase( + f"field_array_{field_name}_{aid}", + pipeline=[{"$currentOp": {field_name: value}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$currentOp should reject an array ({aid}) for the {field_name} field", + ) + for field_name in ("allUsers", "idleConnections") + for aid, value in [ + ("empty", []), + ("single_true", [True]), + ("nested", [[True]]), + ] +] + +# Property [Field Expression Non-Evaluation]: a field value is not evaluated as +# an aggregation expression; expression documents and field-path strings are +# rejected by their literal BSON type. +CURRENTOP_FIELD_EXPRESSION_COERCION_TESTS: list[StageTestCase] = [ + StageTestCase( + f"field_expr_{field_name}_{eid}", + pipeline=[{"$currentOp": {field_name: value}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$currentOp should not evaluate {eid} as an expression for the {field_name} field", + ) + for field_name in ("allUsers", "idleConnections") + for eid, value in [ + ("literal", {"$literal": True}), + ("toBool", {"$toBool": 1}), + ("and", {"$and": []}), + ("field_path", "$active"), + ("root_var", "$$ROOT"), + ] +] + +# Property [Null Value Errors]: a null operand is rejected with a TypeMismatch +# error rather than treated as a valid default form, and any field set to +# literal null is rejected rather than falling back to that field's default. +CURRENTOP_NULL_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "operand_null", + pipeline=[{"$currentOp": None}], + error_code=TYPE_MISMATCH_ERROR, + msg="$currentOp should reject a null operand", + ), + *( + StageTestCase( + f"field_null_{field_name}", + pipeline=[{"$currentOp": {field_name: None}}], + error_code=TYPE_MISMATCH_ERROR, + msg=f"$currentOp should reject a null value for the {field_name} field", + ) + for field_name in ( + "allUsers", + "idleConnections", + "idleCursors", + "idleSessions", + "localOps", + "targetAllNodes", + "truncateOps", + ) + ), +] + +# Property [Unknown Field Errors]: any field name not in the recognized set, +# including wrong-case variants of valid fields and the empty name, produces an +# IDLUnknownField error regardless of the field's value type. +CURRENTOP_UNKNOWN_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"unknown_field_{tid}", + pipeline=[{"$currentOp": {name: value}}], + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg=f"$currentOp should reject unrecognized field {tid}", + ) + for tid, name, value in [ + ("plain", "foo", 1), + ("valid_looking_option", "comment", "x"), + ("case_variant", "AllUsers", True), + ("dollar_prefixed", "$db", "admin"), + ("empty_name", "", True), + ] +] + +# Property [Operand and Default Behavior]: a well-formed operand of optional +# boolean options is accepted and the stage returns ok:1. +CURRENTOP_OPERAND_TESTS: list[StageTestCase] = [ + StageTestCase( + "empty_operand", + pipeline=[{"$currentOp": {}}], + expected={"ok": Eq(1.0)}, + msg="$currentOp should accept an empty operand and use all default values", + ), + *( + StageTestCase( + f"field_{field_name}_{str(value).lower()}", + pipeline=[{"$currentOp": {field_name: value}}], + expected={"ok": Eq(1.0)}, + msg=f"$currentOp should accept {field_name} set to {str(value).lower()}", + ) + for field_name in ( + "allUsers", + "idleConnections", + "idleCursors", + "idleSessions", + "localOps", + "truncateOps", + ) + for value in (True, False) + ), + StageTestCase( + "field_targetAllNodes_false", + pipeline=[{"$currentOp": {"targetAllNodes": False}}], + expected={"ok": Eq(1.0)}, + msg="$currentOp should accept targetAllNodes set to false", + ), + StageTestCase( + "combo_all_documented_true", + pipeline=[ + { + "$currentOp": { + "allUsers": True, + "idleConnections": True, + "idleCursors": True, + "idleSessions": True, + "localOps": True, + "targetAllNodes": False, + } + } + ], + expected={"ok": Eq(1.0)}, + msg="$currentOp should accept all documented fields combined in one operand", + ), + StageTestCase( + "combo_all_documented_false", + pipeline=[ + { + "$currentOp": { + "allUsers": False, + "idleConnections": False, + "idleCursors": False, + "idleSessions": False, + "localOps": False, + "targetAllNodes": False, + } + } + ], + expected={"ok": Eq(1.0)}, + msg="$currentOp should accept all documented fields set to false in one operand", + ), + StageTestCase( + "combo_truncateOps_allUsers", + pipeline=[{"$currentOp": {"truncateOps": True, "allUsers": True}}], + expected={"ok": Eq(1.0)}, + msg="$currentOp should accept truncateOps combined with a documented field", + ), +] + +# Property [Sharded-Only Errors]: targetAllNodes:true is rejected on a +# non-sharded deployment with a FailedToParse error, and localOps:true combined +# with targetAllNodes:true is rejected with a FailedToParse error for the +# undocumented mutual exclusion. +CURRENTOP_SHARDED_ONLY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "sharded_target_all_nodes_true", + pipeline=[{"$currentOp": {"targetAllNodes": True}}], + error_code=FAILED_TO_PARSE_ERROR, + msg="$currentOp should reject targetAllNodes:true on a non-sharded deployment", + ), + StageTestCase( + "sharded_local_ops_and_target_all_nodes_true", + pipeline=[{"$currentOp": {"localOps": True, "targetAllNodes": True}}], + error_code=FAILED_TO_PARSE_ERROR, + msg="$currentOp should reject localOps:true combined with targetAllNodes:true", + ), +] + +CURRENTOP_ERROR_TESTS: list[StageTestCase] = ( + CURRENTOP_OPERAND_TYPE_ERROR_TESTS + + CURRENTOP_FIELD_TYPE_ERROR_TESTS + + CURRENTOP_FIELD_NUMERIC_COERCION_TESTS + + CURRENTOP_FIELD_STRING_COERCION_TESTS + + CURRENTOP_FIELD_ARRAY_COERCION_TESTS + + CURRENTOP_FIELD_EXPRESSION_COERCION_TESTS + + CURRENTOP_NULL_VALUE_ERROR_TESTS + + CURRENTOP_UNKNOWN_FIELD_ERROR_TESTS + + CURRENTOP_SHARDED_ONLY_ERROR_TESTS +) + +CURRENTOP_OPERAND_AND_ERROR_TESTS: list[StageTestCase] = ( + CURRENTOP_OPERAND_TESTS + CURRENTOP_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(CURRENTOP_OPERAND_AND_ERROR_TESTS)) +def test_currentOp_operand(collection, test_case: StageTestCase): + """Test $currentOp accepts well-formed operands and rejects invalid ones.""" + result = execute_admin_command( + collection, + {"aggregate": 1, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_usage.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_usage.py new file mode 100644 index 000000000..c7e6fc621 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/currentOp/test_system_stage_currentOp_usage.py @@ -0,0 +1,103 @@ +"""Tests for $currentOp invocation constraints: namespace, pipeline position, +and transaction context.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_NAMESPACE_ERROR, + NOT_FIRST_STAGE_ERROR, + OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command + + +# Property [Non-Admin Database Rejected]: $currentOp against a database other +# than admin is rejected as an invalid namespace. +@pytest.mark.aggregate +def test_currentOp_rejects_non_admin_database(collection): + """Test $currentOp is rejected when run against a database other than admin.""" + result = execute_command( + collection, + {"aggregate": 1, "pipeline": [{"$currentOp": {}}], "cursor": {}}, + ) + assertResult( + result, + error_code=INVALID_NAMESPACE_ERROR, + msg="$currentOp should reject running against a database other than admin", + raw_res=True, + ) + + +# Property [Named Collection Rejected]: $currentOp run as the named-collection +# aggregate form is rejected as an invalid namespace. +@pytest.mark.aggregate +def test_currentOp_rejects_named_collection(collection): + """Test $currentOp is rejected when run as the named-collection aggregate form.""" + result = execute_admin_command( + collection, + { + "aggregate": "currentOp_named_collection", + "pipeline": [{"$currentOp": {}}], + "cursor": {}, + }, + ) + assertResult( + result, + error_code=INVALID_NAMESPACE_ERROR, + msg="$currentOp should reject the named-collection aggregate form", + raw_res=True, + ) + + +# Property [First Stage Constraint]: $currentOp appearing anywhere other than +# the first position in a pipeline is rejected. A second $currentOp after a +# leading one isolates the ordering error without first tripping the namespace +# constraint that a non-$currentOp leading stage would raise under admin. +@pytest.mark.aggregate +def test_currentOp_rejects_non_first_stage(collection): + """Test $currentOp is rejected when it is not the first stage in a pipeline.""" + result = execute_admin_command( + collection, + { + "aggregate": 1, + "pipeline": [{"$currentOp": {}}, {"$currentOp": {}}], + "cursor": {}, + }, + ) + assertResult( + result, + error_code=NOT_FIRST_STAGE_ERROR, + msg="$currentOp should reject appearing as a non-first pipeline stage", + raw_res=True, + ) + + +# Property [Transaction Context Rejected]: $currentOp run inside a +# multi-document transaction is rejected with OperationNotSupportedInTransaction. +@pytest.mark.aggregate +@pytest.mark.requires(transactions=True) +def test_currentOp_in_transaction_error(collection): + """Test $currentOp is rejected when run inside a multi-document transaction.""" + collection.insert_one({"_id": 1, "v": 0}) + client = collection.database.client + session = client.start_session() + session.start_transaction() + try: + collection.update_one({"_id": 1}, {"$set": {"v": 1}}, session=session) + result = execute_admin_command( + collection, + {"aggregate": 1, "pipeline": [{"$currentOp": {}}], "cursor": {}}, + session=session, + ) + finally: + session.abort_transaction() + session.end_session() + assertResult( + result, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$currentOp should be rejected inside a multi-document transaction", + raw_res=True, + ) diff --git a/documentdb_tests/framework/executor.py b/documentdb_tests/framework/executor.py index ba3779533..4ce374799 100644 --- a/documentdb_tests/framework/executor.py +++ b/documentdb_tests/framework/executor.py @@ -32,20 +32,21 @@ def execute_command(collection, command: Dict, codec_options=TZ_AWARE_CODEC, ses return e -def execute_admin_command(collection, command: Dict) -> Any: +def execute_admin_command(collection, command: Dict, session=None) -> Any: """ Execute a DocumentDB command on admin database and return result or exception. Args: collection: DocumentDB collection command: Command to execute via runCommand + session: Optional ClientSession for session-aware commands. Returns: Result if successful, Exception if failed """ try: db = collection.database.client.admin - result = db.command(command) + result = db.command(command, session=session) return result except Exception as e: return e From 2969bccbcf0f5f3816b89f80078f91afeeb769d7 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:57:06 -0700 Subject: [PATCH 11/51] Add `whatsmyuri` command tests (#643) Signed-off-by: Alina (Xi) Li --- .../commands/whatsmyuri/__init__.py | 0 .../test_whatsmyuri_argument_handling.py | 230 ++++++++++++++++++ .../whatsmyuri/test_whatsmyuri_consistency.py | 76 ++++++ .../test_whatsmyuri_error_conditions.py | 68 ++++++ .../test_whatsmyuri_response_structure.py | 75 ++++++ 5 files changed, 449 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_argument_handling.py new file mode 100644 index 000000000..727f62ff7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_argument_handling.py @@ -0,0 +1,230 @@ +"""Tests for whatsmyuri command argument handling. + +Validates that whatsmyuri accepts any BSON type as its argument value +and ignores unrecognized fields. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +# Property [Type Acceptance]: whatsmyuri accepts all BSON types as the command field value. +ARGUMENT_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "int_1", + command={"whatsmyuri": 1}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept int 1", + ), + DiagnosticTestCase( + "int_0", + command={"whatsmyuri": 0}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept int 0", + ), + DiagnosticTestCase( + "int_neg1", + command={"whatsmyuri": -1}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept int -1", + ), + DiagnosticTestCase( + "bool_true", + command={"whatsmyuri": True}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept true", + ), + DiagnosticTestCase( + "bool_false", + command={"whatsmyuri": False}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept false", + ), + DiagnosticTestCase( + "string", + command={"whatsmyuri": "hello"}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept string", + ), + DiagnosticTestCase( + "empty_string", + command={"whatsmyuri": ""}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept empty string", + ), + DiagnosticTestCase( + "null", + command={"whatsmyuri": None}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept null", + ), + DiagnosticTestCase( + "empty_object", + command={"whatsmyuri": {}}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept empty object", + ), + DiagnosticTestCase( + "nested_object", + command={"whatsmyuri": {"a": {"b": 1}}}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept nested object", + ), + DiagnosticTestCase( + "empty_array", + command={"whatsmyuri": []}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept empty array", + ), + DiagnosticTestCase( + "array_with_elements", + command={"whatsmyuri": [1, 2, 3]}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept array with elements", + ), + DiagnosticTestCase( + "double", + command={"whatsmyuri": 1.5}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept double", + ), + DiagnosticTestCase( + "negative_double", + command={"whatsmyuri": -1.5}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept negative double", + ), + DiagnosticTestCase( + "large_int", + command={"whatsmyuri": 999_999_999}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept large int", + ), + DiagnosticTestCase( + "int64", + command={"whatsmyuri": Int64(1)}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept int64", + ), + DiagnosticTestCase( + "decimal128", + command={"whatsmyuri": Decimal128("1")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept decimal128", + ), + DiagnosticTestCase( + "decimal128_nan", + command={"whatsmyuri": Decimal128("NaN")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept decimal128 NaN", + ), + DiagnosticTestCase( + "decimal128_infinity", + command={"whatsmyuri": Decimal128("Infinity")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept decimal128 Infinity", + ), + DiagnosticTestCase( + "decimal128_neg_zero", + command={"whatsmyuri": Decimal128("-0")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept decimal128 negative zero", + ), + DiagnosticTestCase( + "infinity", + command={"whatsmyuri": float("inf")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept infinity", + ), + DiagnosticTestCase( + "neg_infinity", + command={"whatsmyuri": float("-inf")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept negative infinity", + ), + DiagnosticTestCase( + "nan", + command={"whatsmyuri": float("nan")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept NaN", + ), + DiagnosticTestCase( + "date", + command={"whatsmyuri": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept date", + ), + DiagnosticTestCase( + "binData", + command={"whatsmyuri": Binary(b"")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept binData", + ), + DiagnosticTestCase( + "objectId", + command={"whatsmyuri": ObjectId()}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept objectId", + ), + DiagnosticTestCase( + "regex", + command={"whatsmyuri": Regex("test")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept regex", + ), + DiagnosticTestCase( + "timestamp", + command={"whatsmyuri": Timestamp(0, 0)}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept timestamp", + ), + DiagnosticTestCase( + "minKey", + command={"whatsmyuri": MinKey()}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept minKey", + ), + DiagnosticTestCase( + "maxKey", + command={"whatsmyuri": MaxKey()}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept maxKey", + ), + DiagnosticTestCase( + "code", + command={"whatsmyuri": Code("function(){}")}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should accept JavaScript code", + ), +] + +# Property [Extra Fields Ignored]: whatsmyuri ignores unrecognized fields. +EXTRA_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "extra_field_ignored", + command={"whatsmyuri": 1, "unknownField": 1}, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should succeed even with unrecognized fields", + ), +] + +ALL_TESTS = ARGUMENT_TYPE_TESTS + EXTRA_FIELD_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_whatsmyuri_argument_handling(collection, test): + """Test whatsmyuri argument handling.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_consistency.py new file mode 100644 index 000000000..f5dce61bc --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_consistency.py @@ -0,0 +1,76 @@ +"""Tests for whatsmyuri command consistency and database independence. + +Validates that whatsmyuri returns consistent results across calls, +databases, and is unaffected by server settings. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import ( + assertProperties, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +# Property [Database Independence]: whatsmyuri succeeds on any database. +DATABASE_INDEPENDENCE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "any_database", + command={"whatsmyuri": 1}, + use_admin=False, + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should succeed on non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DATABASE_INDEPENDENCE_TESTS)) +def test_whatsmyuri_consistency(collection, test): + """Test whatsmyuri consistency.""" + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_whatsmyuri_idempotent(collection): + """Test whatsmyuri idempotency.""" + result1 = execute_admin_command(collection, {"whatsmyuri": 1}) + result2 = execute_admin_command(collection, {"whatsmyuri": 1}) + assertSuccess( + result2, + expected=result1, + msg="whatsmyuri should return identical results across calls", + raw_res=True, + ) + + +def test_whatsmyuri_same_result_any_database(collection): + """Test whatsmyuri returns same result from admin and non-admin database.""" + admin_result = execute_admin_command(collection, {"whatsmyuri": 1}) + db_result = execute_command(collection, {"whatsmyuri": 1}) + assertSuccess( + db_result, + expected=admin_result, + msg="whatsmyuri should return same result from any database", + raw_res=True, + ) + + +def test_whatsmyuri_nonexistent_database(collection): + """Test whatsmyuri on a non-existent database.""" + other_db = f"{collection.name}_nonexistent_db" + other_col = collection.database.client[other_db][collection.name] + result = execute_command(other_col, {"whatsmyuri": 1}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="whatsmyuri should succeed on non-existent database", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_error_conditions.py new file mode 100644 index 000000000..1d22a4dee --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_error_conditions.py @@ -0,0 +1,68 @@ +"""Tests for whatsmyuri command error conditions. + +Validates that invalid usages of whatsmyuri produce appropriate errors. +Uses CommandTestCase because the aggregation stage test needs ctx.collection +for the aggregate command. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_FOUND_ERROR, + UNKNOWN_PIPELINE_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +# Property [Case Sensitivity]: whatsmyuri is case-sensitive and rejects mismatched casing. +CASE_SENSITIVITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "case_sensitive_capital_w", + command={"WhatsMyUri": 1}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="whatsmyuri should reject camel-cased command name", + ), + CommandTestCase( + "case_sensitive_all_upper", + command={"WHATSMYURI": 1}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="whatsmyuri should reject all-uppercase command name", + ), +] + +# Property [Not a Pipeline Stage]: whatsmyuri is not usable as an aggregation stage. +PIPELINE_STAGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "as_aggregation_stage", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$whatsmyuri": {}}], + "cursor": {}, + }, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="whatsmyuri should not be usable as an aggregation stage", + ), +] + +ALL_TESTS = CASE_SENSITIVITY_TESTS + PIPELINE_STAGE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_whatsmyuri_error_conditions(collection, test): + """Test whatsmyuri error conditions.""" + ctx = CommandContext.from_collection(collection) + cmd = test.build_command(ctx) + # Case sensitivity tests target the admin db; the aggregate test does not. + if next(iter(cmd)).lower() == "whatsmyuri": + result = execute_admin_command(collection, cmd) + else: + result = execute_command(collection, cmd) + assertResult(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_response_structure.py new file mode 100644 index 000000000..0e754ac17 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/whatsmyuri/test_whatsmyuri_response_structure.py @@ -0,0 +1,75 @@ +"""Tests for whatsmyuri command response structure. + +Validates presence, types, and values of response fields returned +by whatsmyuri. The response contains a 'you' field with the client's +connection URI (ip:port) and the standard 'ok' field. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, NonEmptyStr + +pytestmark = pytest.mark.admin + + +# Property [Response Structure]: whatsmyuri returns ok and a non-empty you field. +PROPERTY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="ok_is_1", + checks={"ok": Eq(1.0)}, + msg="whatsmyuri should return ok equal to 1.0", + ), + DiagnosticTestCase( + id="ok_is_double", + checks={"ok": IsType("double")}, + msg="whatsmyuri should return ok as a double", + ), + DiagnosticTestCase( + id="you_exists", + checks={"you": Exists()}, + msg="whatsmyuri should return a you field", + ), + DiagnosticTestCase( + id="you_is_string", + checks={"you": IsType("string")}, + msg="whatsmyuri should return you as a string", + ), + DiagnosticTestCase( + id="you_is_non_empty", + checks={"you": NonEmptyStr()}, + msg="whatsmyuri should return a non-empty you field containing the client URI", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PROPERTY_TESTS)) +def test_whatsmyuri_response_properties(collection, test): + """Test whatsmyuri response structure.""" + result = execute_admin_command(collection, {"whatsmyuri": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +# Property [URI Format]: the you field contains an ip:port pair with a colon separator. +def test_whatsmyuri_you_contains_colon(collection): + """Test whatsmyuri you field contains ip:port separator.""" + result = execute_admin_command(collection, {"whatsmyuri": 1}) + you = result["you"] + if ":" not in you: + raise AssertionError(f"whatsmyuri you field should contain ':' (ip:port), got {you!r}") + + +def test_whatsmyuri_you_port_is_numeric(collection): + """Test whatsmyuri you field has a numeric port.""" + result = execute_admin_command(collection, {"whatsmyuri": 1}) + you = result["you"] + port = you.rsplit(":", 1)[-1] + if not port.isdigit(): + raise AssertionError( + f"whatsmyuri you field should have a numeric port after ':', got {you!r}" + ) From 299f0a971aa72f72e6b406d937b1ac8958d1ac68 Mon Sep 17 00:00:00 2001 From: krishnasai453 Date: Tue, 30 Jun 2026 23:30:12 -0400 Subject: [PATCH 12/51] added complete fix for bitAnd operator Signed-off-by: krishnasai453 --- .../test_expression_bitAnd_additional.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_additional.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_additional.py b/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_additional.py new file mode 100644 index 000000000..d5538705b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_additional.py @@ -0,0 +1,45 @@ +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR + +# Array expression tests: $bitAnd where the first operand is an array expression +ARRAY_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_expression", + expression={"$bitAnd": [["$a", "$b"], 3]}, + doc={"a": 1, "b": 3}, + error_code=TYPE_MISMATCH_ERROR, + msg="$bitAnd should reject an array expression as first operand", + ), +] + +# Object expression tests: $bitAnd where the first operand is an object expression +OBJECT_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "object_expression", + expression={"$bitAnd": [{"x": "$a", "y": "$b"}, 0]}, + doc={"a": 1, "b": 3}, + error_code=TYPE_MISMATCH_ERROR, + msg="$bitAnd should reject an object expression as an input", + ), +] + +ALL_ADDITIONAL_TESTS = ARRAY_EXPRESSION_TESTS + OBJECT_EXPRESSION_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_ADDITIONAL_TESTS)) +def test_bitAnd_expression_additional(collection, test): + """Test $bitAnd with array and object expression inputs.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) From 0bde93209616f41d08c7896405f78b6c40f8e04d Mon Sep 17 00:00:00 2001 From: krishnasai453 Date: Tue, 30 Jun 2026 23:36:38 -0400 Subject: [PATCH 13/51] removed the strategy text Signed-off-by: krishnasai453 --- ...est_expression_bitAnd_testing_strategy.txt | 99 ------------------- 1 file changed, 99 deletions(-) delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_testing_strategy.txt diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_testing_strategy.txt b/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_testing_strategy.txt deleted file mode 100644 index 2e9e5d735..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/bitwise/bitAnd/test_expression_bitAnd_testing_strategy.txt +++ /dev/null @@ -1,99 +0,0 @@ -""" -Tests for $bitAnd expression type smoke tests. - -Covers literal, field reference, expression operator, array expression, -and object expression. -""" - -import pytest - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression, - execute_expression_with_insert, -) -from documentdb_tests.framework.parametrize import pytest_params - - -LITERAL_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "literal_zero_and", - expression={"$bitAnd": [5, 3]}, - expected=1, - msg="$bitAnd of two literals should compute bitwise AND", - ), - ExpressionTestCase( - "literal_all_zero", - expression={"$bitAnd": [0, 7]}, - expected=0, - msg="$bitAnd with zero should produce zero", - ), -] - -FIELD_REFERENCE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "field_refs", - expression={"$bitAnd": ["$a", "$b"]}, - doc={"a": 5, "b": 3}, - expected=1, - msg="$bitAnd should compute bitwise AND of two field values", - ), - ExpressionTestCase( - "field_refs_negative", - expression={"$bitAnd": ["$a", "$b"]}, - doc={"a": 12, "b": 10}, - expected=8, - msg="$bitAnd should compute bitwise AND for other field values", - ), -] - -EXPRESSION_OPERATOR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "nested_expression", - expression={"$bitAnd": [{"$add": [4, 1]}, {"$subtract": [6, 1]}]}, - expected=5, - msg="$bitAnd should accept nested expression operator inputs", - ), -] - -ARRAY_EXPRESSION_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "array_expression", - expression={"$bitAnd": [["$a", "$b"], 3]}, - doc={"a": 1, "b": 3}, - expected=1, - msg="$bitAnd should accept an array expression as first operand", - ), -] - -OBJECT_EXPRESSION_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "object_expression", - expression={"$bitAnd": [{"x": "$a", "y": "$b"}, 0]}, - doc={"a": 1, "b": 3}, - expected=0, - msg="$bitAnd should accept an object expression as an input", - ), -] - -ALL_INSERT_TESTS = ( - FIELD_REFERENCE_TESTS - + EXPRESSION_OPERATOR_TESTS - + ARRAY_EXPRESSION_TESTS - + OBJECT_EXPRESSION_TESTS -) - - -@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) -def test_bitAnd_expression_types_literal(collection, test): - result = execute_expression(collection, test.expression) - assert_expression_result(result, expected=test.expected, msg=test.msg) - - -@pytest.mark.parametrize("test", pytest_params(ALL_INSERT_TESTS)) -def test_bitAnd_expression_types_insert(collection, test): - result = execute_expression_with_insert(collection, test.expression, test.doc) - assert_expression_result(result, expected=test.expected, msg=test.msg) \ No newline at end of file From 6972e932e97a399cbc52030911320cf39eac7cbe Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:12:06 -0700 Subject: [PATCH 14/51] Add $planCacheListFilters command tests (#588) Signed-off-by: Alina (Xi) Li --- .../commands/planCacheListFilters/__init__.py | 0 .../test_planCacheListFilters_core.py | 260 +++++++++++++ .../test_planCacheListFilters_errors.py | 194 ++++++++++ .../test_planCacheListFilters_lifecycle.py | 270 ++++++++++++++ .../test_planCacheListFilters_response.py | 350 ++++++++++++++++++ 5 files changed, 1074 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_core.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_lifecycle.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_response.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/__init__.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_core.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_core.py new file mode 100644 index 000000000..638024741 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_core.py @@ -0,0 +1,260 @@ +"""Tests for planCacheListFilters command core behavior and field acceptance.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + 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.target_collection import ( + CappedCollection, + ClusteredCollection, + NamedCollection, +) + +# Property [Basic Success]: planCacheListFilters returns ok: 1.0 and empty +# filters array on existing, empty, and non-existent collections. +LIST_FILTERS_BASIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "basic_with_documents", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on collection with documents", + ), + CommandTestCase( + "basic_empty_collection", + docs=[], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on empty collection", + ), + CommandTestCase( + "basic_nonexistent_collection", + docs=None, + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on non-existent collection", + ), +] + +# Property [Capped Collection]: planCacheListFilters succeeds on capped +# collections. +LIST_FILTERS_CAPPED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_collection", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on a capped collection", + ), +] + +# Property [Clustered Collection]: planCacheListFilters succeeds on clustered +# collections. +LIST_FILTERS_CLUSTERED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clustered_collection", + target_collection=ClusteredCollection(), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed on a clustered collection", + ), +] + +# Property [Unknown Fields Accepted]: planCacheListFilters silently accepts +# unrecognized fields without error. +LIST_FILTERS_UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_unknown_field", + command=lambda ctx: {"planCacheListFilters": ctx.collection, "foo": "bar"}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should silently accept unknown field", + ), + CommandTestCase( + "accepts_case_variant_Comment", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "Comment": "test", + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should treat capitalized Comment as unknown field", + ), + CommandTestCase( + "accepts_case_variant_Query", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "Query": {"a": 1}, + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should treat capitalized Query as unknown field", + ), + CommandTestCase( + "accepts_case_variant_Sort", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "Sort": {"a": 1}, + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should treat capitalized Sort as unknown field", + ), +] + +# Property [Collection Name Edge Cases]: planCacheListFilters succeeds with +# special characters, unicode, and long collection names. +LIST_FILTERS_NAME_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_accepts_long_suffix", + target_collection=NamedCollection(suffix="_" + "a" * 150), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with a long collection name", + ), + CommandTestCase( + "name_accepts_hyphen", + target_collection=NamedCollection(suffix="_my-coll"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with hyphen in name", + ), + CommandTestCase( + "name_accepts_unicode", + target_collection=NamedCollection(suffix="_\u00e9"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with unicode name", + ), + CommandTestCase( + "name_accepts_single_char", + target_collection=NamedCollection(suffix="_x"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with single-character suffix name", + ), + CommandTestCase( + "name_accepts_underscores", + target_collection=NamedCollection(suffix="_my_test_coll"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with underscores in name", + ), +] + +# Property [Comment Edge Cases]: planCacheListFilters succeeds with edge-case +# comment values. +LIST_FILTERS_COMMENT_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "comment_accepts_long_string", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "comment": "x" * 10_000, + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with very long comment", + ), + CommandTestCase( + "comment_accepts_empty_string", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "comment": "", + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with empty string comment", + ), + CommandTestCase( + "comment_accepts_nested_object", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "comment": {"a": {"b": {"c": {"d": {"e": 1}}}}}, + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with deeply nested object comment", + ), + CommandTestCase( + "comment_accepts_mixed_array", + command=lambda ctx: { + "planCacheListFilters": ctx.collection, + "comment": [1, "two", True, None, {"a": 1}], + }, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should succeed with array of mixed types as comment", + ), +] + +_BSON_TYPE_VALUES = [ + ("document", {"a": 1}), + ("empty_document", {}), + ("string", "test"), + ("int32", 123), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, 2]), + ("empty_array", []), + ("binary", Binary(b"\x00")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(0, 0)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), +] + +# Property [Comment Type Acceptance]: the comment field accepts any valid +# BSON type. +LIST_FILTERS_COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command=lambda ctx, v=val: { + "planCacheListFilters": ctx.collection, + "comment": v, + }, + expected={"filters": [], "ok": 1.0}, + msg=f"planCacheListFilters should accept comment of type {tid}", + ) + for tid, val in _BSON_TYPE_VALUES +] + +LIST_FILTERS_CORE_TESTS: list[CommandTestCase] = ( + LIST_FILTERS_BASIC_TESTS + + LIST_FILTERS_CAPPED_TESTS + + LIST_FILTERS_CLUSTERED_TESTS + + LIST_FILTERS_UNKNOWN_FIELD_TESTS + + LIST_FILTERS_NAME_EDGE_CASE_TESTS + + LIST_FILTERS_COMMENT_EDGE_CASE_TESTS + + LIST_FILTERS_COMMENT_TYPE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(LIST_FILTERS_CORE_TESTS)) +def test_planCacheListFilters_core(database_client, collection, test): + """Test planCacheListFilters command core behavior and field 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/query-planning/commands/planCacheListFilters/test_planCacheListFilters_errors.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_errors.py new file mode 100644 index 000000000..33feec3e3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_errors.py @@ -0,0 +1,194 @@ +"""Tests for planCacheListFilters command error cases.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + 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 ( + TimeseriesCollection, + ViewCollection, +) + +# Property [View Rejection]: planCacheListFilters is not supported on views. +LIST_FILTERS_VIEW_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_rejection", + target_collection=ViewCollection(), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="planCacheListFilters should be rejected on a view", + ), +] + +# Property [Timeseries Rejection]: planCacheListFilters is not supported on +# timeseries collections. +LIST_FILTERS_TIMESERIES_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_rejection", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="planCacheListFilters should be rejected on a timeseries collection", + ), +] + +# Property [Field Type Rejection]: all non-string BSON types for the +# planCacheListFilters field produce an invalid namespace error. +LIST_FILTERS_NAME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_type_int32", + command={"planCacheListFilters": 123}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject int32 collection name", + ), + CommandTestCase( + "name_type_int64", + command={"planCacheListFilters": Int64(1)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Int64 collection name", + ), + CommandTestCase( + "name_type_double", + command={"planCacheListFilters": 1.5}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject double collection name", + ), + CommandTestCase( + "name_type_decimal128", + command={"planCacheListFilters": Decimal128("1")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Decimal128 collection name", + ), + CommandTestCase( + "name_type_bool_true", + command={"planCacheListFilters": True}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject bool true collection name", + ), + CommandTestCase( + "name_type_bool_false", + command={"planCacheListFilters": False}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject bool false collection name", + ), + CommandTestCase( + "name_type_null", + command={"planCacheListFilters": None}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject null collection name", + ), + CommandTestCase( + "name_type_array", + command={"planCacheListFilters": []}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject array collection name", + ), + CommandTestCase( + "name_type_object", + command={"planCacheListFilters": {}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject object collection name", + ), + CommandTestCase( + "name_type_objectid", + command={"planCacheListFilters": ObjectId()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject ObjectId collection name", + ), + CommandTestCase( + "name_type_binary", + command={"planCacheListFilters": Binary(b"\x00")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Binary collection name", + ), + CommandTestCase( + "name_type_datetime", + command={"planCacheListFilters": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject datetime collection name", + ), + CommandTestCase( + "name_type_timestamp", + command={"planCacheListFilters": Timestamp(0, 0)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Timestamp collection name", + ), + CommandTestCase( + "name_type_regex", + command={"planCacheListFilters": Regex(".*")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Regex collection name", + ), + CommandTestCase( + "name_type_code", + command={"planCacheListFilters": Code("function(){}")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject Code collection name", + ), + CommandTestCase( + "name_type_minkey", + command={"planCacheListFilters": MinKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject MinKey collection name", + ), + CommandTestCase( + "name_type_maxkey", + command={"planCacheListFilters": MaxKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject MaxKey collection name", + ), +] + +# Property [Namespace Edge Cases]: empty string and null byte collection +# names produce an invalid namespace error. +LIST_FILTERS_NAME_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_empty_string", + command={"planCacheListFilters": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject empty string collection name", + ), + CommandTestCase( + "name_null_byte_embedded", + command={"planCacheListFilters": "test\x00coll"}, + error_code=INVALID_NAMESPACE_ERROR, + msg="planCacheListFilters should reject null byte in collection name", + ), +] + +LIST_FILTERS_ERROR_TESTS: list[CommandTestCase] = ( + LIST_FILTERS_VIEW_REJECTION_TESTS + + LIST_FILTERS_TIMESERIES_REJECTION_TESTS + + LIST_FILTERS_NAME_TYPE_ERROR_TESTS + + LIST_FILTERS_NAME_EDGE_CASE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(LIST_FILTERS_ERROR_TESTS)) +def test_planCacheListFilters_errors(database_client, collection, test): + """Test planCacheListFilters command error cases.""" + 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/query-planning/commands/planCacheListFilters/test_planCacheListFilters_lifecycle.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_lifecycle.py new file mode 100644 index 000000000..064cf6dd1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_lifecycle.py @@ -0,0 +1,270 @@ +"""Tests for planCacheListFilters filter lifecycle and integration.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertProperties, assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Len + +# Property [Multiple Filters — Two Shapes]: planCacheListFilters returns both +# filters when two different query shapes are set. +LIST_FILTERS_MULTIPLE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "two_shapes", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"b": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"b": 1}, "indexes": [{"b": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(2)}, + msg="planCacheListFilters should return 2 filters for 2 query shapes", + ), + CommandTestCase( + "same_query_different_sort", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "sort": {"a": 1}, + "indexes": [{"a": 1}], + }, + ), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "sort": {"a": -1}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(2)}, + msg="planCacheListFilters should return 2 filters when query matches but sort differs", + ), + CommandTestCase( + "three_shapes", + docs=[{"_id": 1, "a": 1, "b": 1, "c": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"b": 1}), + coll.create_index({"c": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"b": 1}, "indexes": [{"b": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"c": 1}, "indexes": [{"c": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(3)}, + msg="planCacheListFilters should return 3 filters for 3 query shapes", + ), +] + +# Property [Filter Override]: re-setting a filter for the same query shape +# overrides the previous indexes. +LIST_FILTERS_OVERRIDE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "override_indexes", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"a": 1, "b": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "indexes": [{"a": 1, "b": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters": Len(1), + "filters.0.indexes": Eq([{"a": 1, "b": 1}]), + }, + msg="planCacheListFilters should have 1 filter with overridden indexes", + ), +] + +# Property [Lifecycle — Empty Before Set]: planCacheListFilters returns empty +# filters before any filters are set. +LIST_FILTERS_EMPTY_BEFORE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "returns_empty_before_any_set", + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Eq([]), "ok": Eq(1.0)}, + msg="planCacheListFilters should return empty filters initially", + ), +] + +# Property [Lifecycle — Present After Set]: planCacheListFilters returns the +# filter after planCacheSetFilter is called. +LIST_FILTERS_PRESENT_AFTER_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "returns_filter_after_set", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(1)}, + msg="planCacheListFilters should have 1 filter after set", + ), +] + +# Property [Lifecycle — Empty After Clear All]: planCacheListFilters returns +# empty filters after planCacheClearFilters clears all filters. +LIST_FILTERS_CLEAR_ALL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "returns_empty_after_clear_all", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command(coll, {"planCacheClearFilters": coll.name}), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Eq([]), "ok": Eq(1.0)}, + msg="planCacheListFilters should return empty filters after clear all", + ), +] + +# Property [Lifecycle — Remaining After Selective Clear]: after clearing one +# query shape, the remaining filter is still returned. +LIST_FILTERS_SELECTIVE_CLEAR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "returns_remaining_after_selective_clear", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"b": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"b": 1}, "indexes": [{"b": 1}]}, + ), + execute_command( + coll, + {"planCacheClearFilters": coll.name, "query": {"a": 1}}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters": Len(1), + "filters.0.query": Eq({"b": 1}), + }, + msg="planCacheListFilters should have 1 remaining filter for query {b: 1}", + ), +] + +# Property [Index Lifecycle]: filters persist even after the referenced +# index is dropped. +LIST_FILTERS_INDEX_DROPPED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "filter_persists_after_index_dropped", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}, name="a_1"), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + coll.drop_index("a_1"), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters": Len(1), + "filters.0.query": Eq({"a": 1}), + }, + msg="planCacheListFilters should still return filter after index is dropped", + ), +] + +LIST_FILTERS_LIFECYCLE_TESTS: list[CommandTestCase] = ( + LIST_FILTERS_MULTIPLE_TESTS + + LIST_FILTERS_OVERRIDE_TESTS + + LIST_FILTERS_EMPTY_BEFORE_TESTS + + LIST_FILTERS_PRESENT_AFTER_TESTS + + LIST_FILTERS_CLEAR_ALL_TESTS + + LIST_FILTERS_SELECTIVE_CLEAR_TESTS + + LIST_FILTERS_INDEX_DROPPED_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(LIST_FILTERS_LIFECYCLE_TESTS)) +def test_planCacheListFilters_lifecycle(database_client, collection, test): + """Test planCacheListFilters filter lifecycle and integration.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertProperties(result, test.build_expected(ctx), msg=test.msg, raw_res=True) + + +# Property [Collection Isolation]: filters set on collection A are not +# visible when listing filters on collection B. +def test_planCacheListFilters_collection_isolation(database_client, collection): + """Test planCacheListFilters is scoped to the specific collection.""" + collection.insert_one({"_id": 1, "a": 1}) + collection.create_index({"a": 1}) + execute_command( + collection, + {"planCacheSetFilter": collection.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ) + + other_coll = database_client.create_collection(f"{collection.name}_other") + try: + result = execute_command(other_coll, {"planCacheListFilters": other_coll.name}) + assertResult( + result, + expected={"filters": [], "ok": 1.0}, + msg="planCacheListFilters should not show filters from another collection", + raw_res=True, + ) + finally: + database_client.drop_collection(other_coll.name) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_response.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_response.py new file mode 100644 index 000000000..f6d771464 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_planCacheListFilters_response.py @@ -0,0 +1,350 @@ +"""Tests for planCacheListFilters response structure and filter content.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + ContainsElement, + Eq, + Exists, + IsType, + Len, +) + +# Property [Ok Field Type]: planCacheListFilters ok field is of type double. +LIST_FILTERS_OK_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ok_type_double", + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"ok": IsType("double")}, + msg="planCacheListFilters ok field should be of type double", + ), +] + +# Property [Filters Field Type]: planCacheListFilters filters field is of +# type array even when empty. +LIST_FILTERS_ARRAY_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "filters_type_array", + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": IsType("array")}, + msg="planCacheListFilters filters field should be of type array", + ), +] + +# Property [Single Filter Entry]: after setting one filter, the response +# contains one entry with matching query, sort, projection, and indexes. +LIST_FILTERS_SINGLE_ENTRY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_filter_entry", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters": Len(1), + "filters.0.query": Eq({"a": 1}), + "filters.0.indexes": Eq([{"a": 1}]), + }, + msg="planCacheListFilters should return one filter entry with matching query and indexes", + ), + CommandTestCase( + "same_shape_different_value", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 999}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters": Len(1)}, + msg="planCacheListFilters should return 1 filter when queries differ only in value", + ), +] + +# Property [Default Sort]: filter entry sort is empty document when sort was +# not specified in planCacheSetFilter. +# Property [Default Projection]: filter entry projection is empty document +# when projection was not specified in planCacheSetFilter. +LIST_FILTERS_DEFAULTS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "default_sort", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.sort": Eq({})}, + msg="planCacheListFilters should default sort to empty document", + ), + CommandTestCase( + "default_projection", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.projection": Eq({})}, + msg="planCacheListFilters should default projection to empty document", + ), +] + +# Property [Sort In Entry]: after setting a filter with sort, the filter +# entry includes the sort document. +# Property [Projection In Entry]: after setting a filter with projection, +# the filter entry includes the projection document. +LIST_FILTERS_SHAPE_FIELDS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "sort_in_entry", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1, "b": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "sort": {"b": 1}, + "indexes": [{"a": 1, "b": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.sort": Eq({"b": 1})}, + msg="planCacheListFilters should return sort matching planCacheSetFilter", + ), + CommandTestCase( + "projection_in_entry", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "projection": {"a": 1}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.projection": Eq({"a": 1})}, + msg="planCacheListFilters should return projection matching planCacheSetFilter", + ), +] + +# Property [Collation In Entry]: after setting a filter with collation, the +# filter entry includes the collation document. +LIST_FILTERS_COLLATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_basic", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "collation": {"locale": "en"}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters.0.collation": Exists(), + "filters.0.collation.locale": Eq("en"), + }, + msg="planCacheListFilters should return collation with locale en", + ), + CommandTestCase( + "collation_full", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "collation": {"locale": "fr", "strength": 2}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters.0.collation.locale": Eq("fr"), + "filters.0.collation.strength": Eq(2), + }, + msg="planCacheListFilters should return collation with locale fr and strength 2", + ), +] + +# Property [All Shape Parameters]: after setting a filter with all shape +# parameters, all fields are present in the filter entry. +LIST_FILTERS_ALL_PARAMS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_shape_params", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "sort": {"b": 1}, + "projection": {"a": 1}, + "collation": {"locale": "en"}, + "indexes": [{"a": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters.0.query": Eq({"a": 1}), + "filters.0.sort": Eq({"b": 1}), + "filters.0.projection": Eq({"a": 1}), + "filters.0.collation": Exists(), + "filters.0.indexes": Eq([{"a": 1}]), + }, + msg="planCacheListFilters should return all shape parameters in filter entry", + ), +] + +# Property [Multiple Indexes]: filter entry indexes field contains all +# indexes specified in planCacheSetFilter. +LIST_FILTERS_MULTI_INDEX_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "entry_indexes_contains_all_specified", + docs=[{"_id": 1, "a": 1, "b": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + coll.create_index({"a": 1, "b": 1}), + execute_command( + coll, + { + "planCacheSetFilter": coll.name, + "query": {"a": 1}, + "indexes": [{"a": 1}, {"a": 1, "b": 1}], + }, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.indexes": Len(2)}, + msg="planCacheListFilters should return both specified indexes", + ), +] + +# Property [Filter Entry Field Types]: query, sort, projection are documents +# and indexes is an array. +LIST_FILTERS_ENTRY_TYPES_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "entry_fields_have_correct_types", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={ + "filters.0.query": IsType("object"), + "filters.0.sort": IsType("object"), + "filters.0.projection": IsType("object"), + "filters.0.indexes": IsType("array"), + }, + msg="planCacheListFilters filter entry fields should have correct types", + ), +] + +# Property [Index By Name]: after setting a filter with index specified by +# name, the name is returned in the indexes array. +# Property [Index By Key Pattern]: after setting a filter with index +# specified by key pattern, the key pattern is returned in the indexes array. +LIST_FILTERS_INDEX_SPEC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "index_by_name", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}, name="a_1"), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": ["a_1"]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.indexes": ContainsElement("a_1")}, + msg="planCacheListFilters should return index name in indexes array", + ), + CommandTestCase( + "index_by_key_pattern", + docs=[{"_id": 1, "a": 1}], + setup=lambda coll: ( + coll.create_index({"a": 1}), + execute_command( + coll, + {"planCacheSetFilter": coll.name, "query": {"a": 1}, "indexes": [{"a": 1}]}, + ), + ), + command=lambda ctx: {"planCacheListFilters": ctx.collection}, + expected={"filters.0.indexes": ContainsElement({"a": 1})}, + msg="planCacheListFilters should return key pattern in indexes array", + ), +] + +LIST_FILTERS_RESPONSE_TESTS: list[CommandTestCase] = ( + LIST_FILTERS_OK_TYPE_TESTS + + LIST_FILTERS_ARRAY_TYPE_TESTS + + LIST_FILTERS_SINGLE_ENTRY_TESTS + + LIST_FILTERS_DEFAULTS_TESTS + + LIST_FILTERS_SHAPE_FIELDS_TESTS + + LIST_FILTERS_COLLATION_TESTS + + LIST_FILTERS_ALL_PARAMS_TESTS + + LIST_FILTERS_MULTI_INDEX_TESTS + + LIST_FILTERS_ENTRY_TYPES_TESTS + + LIST_FILTERS_INDEX_SPEC_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(LIST_FILTERS_RESPONSE_TESTS)) +def test_planCacheListFilters_response(database_client, collection, test): + """Test planCacheListFilters response structure and filter content.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertProperties(result, test.build_expected(ctx), msg=test.msg, raw_res=True) From 0d2ea39da540bc798aa064475f48ef41026d65a9 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:22:02 -0700 Subject: [PATCH 15/51] Add $planCacheClearFilters command tests (#583) Signed-off-by: Alina (Xi) Li --- .../planCacheClearFilters/__init__.py | 0 .../test_planCacheClearFilters_behavior.py | 96 ++++++ .../test_planCacheClearFilters_core.py | 291 ++++++++++++++++++ .../test_planCacheClearFilters_edge_cases.py | 209 +++++++++++++ .../test_planCacheClearFilters_errors.py | 192 ++++++++++++ .../test_planCacheClearFilters_field_types.py | 117 +++++++ 6 files changed, 905 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_core.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_field_types.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/__init__.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_behavior.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_behavior.py new file mode 100644 index 000000000..3138249d5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_behavior.py @@ -0,0 +1,96 @@ +"""Tests for planCacheClearFilters command behavioral verification. + +Verifies that planCacheClearFilters actually removes index filters, not just +that it returns ok: 1.0. Uses planCacheSetFilter to establish filters and +planCacheListFilters to observe filter state before and after clearing. +""" + +from __future__ import annotations + +from pymongo.collection import Collection + +from documentdb_tests.framework.assertions import assertNotError, assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def _set_filters(collection: Collection) -> None: + """Set two distinct index filters on the collection. + + Raises AssertionError if the filters are not visible via + planCacheListFilters after being set. + """ + collection.insert_many([{"_id": i, "a": i, "b": i % 5} for i in range(10)]) + collection.create_index("a") + collection.create_index("b") + + db = collection.database + db.command( + { + "planCacheSetFilter": collection.name, + "query": {"a": 1}, + "indexes": [{"a": 1}], + } + ) + db.command( + { + "planCacheSetFilter": collection.name, + "query": {"b": 1}, + "indexes": [{"b": 1}], + } + ) + + list_result = execute_command(collection, {"planCacheListFilters": collection.name}) + assertNotError(list_result, msg="planCacheListFilters precondition check") + filters = list_result.get("filters", []) + if len(filters) < 2: + raise AssertionError( + f"Precondition failed: expected at least 2 index filters " + f"after setup, got {len(filters)}" + ) + + +# Property [Clear All Filters]: planCacheClearFilters without query shape +# parameters removes all index filters for the collection. +def test_planCacheClearFilters_clears_all_filters(collection: Collection): + """Test planCacheClearFilters removes all index filters.""" + _set_filters(collection) + + execute_command(collection, {"planCacheClearFilters": collection.name}) + + result = execute_command(collection, {"planCacheListFilters": collection.name}) + assertSuccess( + result, + {"filters": [], "ok": 1.0}, + msg="All index filters should be removed after planCacheClearFilters", + raw_res=True, + ) + + +# Property [Targeted Clear]: planCacheClearFilters with a query shape removes +# only the matching filter and leaves other filters intact. +def test_planCacheClearFilters_targeted_preserves_other_filters(collection: Collection): + """Test planCacheClearFilters with query shape leaves non-targeted filters.""" + _set_filters(collection) + + execute_command( + collection, + {"planCacheClearFilters": collection.name, "query": {"a": 1}}, + ) + + result = execute_command(collection, {"planCacheListFilters": collection.name}) + assertSuccess( + result, + { + "filters": [ + { + "query": {"b": 1}, + "sort": {}, + "projection": {}, + "indexes": [{"b": 1}], + } + ], + "ok": 1.0, + }, + msg="Only the non-targeted filter should remain after targeted planCacheClearFilters", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_core.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_core.py new file mode 100644 index 000000000..eb6bdae36 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_core.py @@ -0,0 +1,291 @@ +"""Tests for planCacheClearFilters command core behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, +) + +# Property [Basic Success]: planCacheClearFilters succeeds on existing, empty, +# and non-existent collections, returning ok: 1.0. +CLEAR_FILTERS_BASIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "basic_with_documents", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on collection with documents", + ), + CommandTestCase( + "basic_empty_collection", + docs=[], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on empty collection", + ), + CommandTestCase( + "basic_nonexistent_collection", + docs=None, + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on non-existent collection", + ), +] + +# Property [Query Shape]: planCacheClearFilters accepts optional query, sort, +# and projection parameters to target a specific index filter. +CLEAR_FILTERS_QUERY_SHAPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_only", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query only", + ), + CommandTestCase( + "query_and_sort", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query and sort", + ), + CommandTestCase( + "query_and_projection", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query and projection", + ), + CommandTestCase( + "query_sort_projection", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + "projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query, sort, and projection", + ), +] + +# Property [Parameter Combinations]: planCacheClearFilters supports various +# valid combinations of all parameters. +CLEAR_FILTERS_PARAM_COMBO_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_sort_projection_comment", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + "projection": {"a": 1}, + "comment": "test", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query, sort, projection, and comment", + ), + CommandTestCase( + "query_sort_projection_collation", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + "projection": {"a": 1}, + "collation": {"locale": "en"}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query, sort, projection, and collation", + ), + CommandTestCase( + "all_parameters", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": {"a": 1}, + "projection": {"a": 1}, + "collation": {"locale": "en"}, + "comment": "test", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept all parameters together", + ), + CommandTestCase( + "comment_only", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "comment": "test"}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept comment without query shape", + ), + CommandTestCase( + "query_and_comment", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "comment": "test", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept query and comment", + ), +] + +# Property [Null Optional Parameters]: when optional parameters are set to +# null, the command treats them as omitted and succeeds. +CLEAR_FILTERS_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_query", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "query": None}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat null query as omitted", + ), + CommandTestCase( + "null_sort", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "sort": None}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat null sort as omitted", + ), + CommandTestCase( + "null_projection", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "projection": None}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat null projection as omitted", + ), + CommandTestCase( + "null_comment", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "comment": None}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat null comment as omitted", + ), + CommandTestCase( + "null_all_optional", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": None, + "sort": None, + "projection": None, + "comment": None, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat all null optional params as omitted", + ), +] + +# Property [Parameter Independence]: sort, projection, and collation succeed +# without query. +CLEAR_FILTERS_INDEPENDENCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "sort_without_query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "sort": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept sort without query", + ), + CommandTestCase( + "projection_without_query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept projection without query", + ), + CommandTestCase( + "sort_and_projection_without_query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "sort": {"a": 1}, + "projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept sort and projection without query", + ), + CommandTestCase( + "collation_without_query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": {"locale": "en"}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept collation without query", + ), +] + +# Property [Capped Collection]: planCacheClearFilters succeeds on capped +# collections. +CLEAR_FILTERS_CAPPED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_collection", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on a capped collection", + ), +] + +# Property [Clustered Collection]: planCacheClearFilters succeeds on clustered +# collections. +CLEAR_FILTERS_CLUSTERED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clustered_collection", + target_collection=ClusteredCollection(), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed on a clustered collection", + ), +] + +CLEAR_FILTERS_CORE_TESTS: list[CommandTestCase] = ( + CLEAR_FILTERS_BASIC_TESTS + + CLEAR_FILTERS_QUERY_SHAPE_TESTS + + CLEAR_FILTERS_PARAM_COMBO_TESTS + + CLEAR_FILTERS_NULL_TESTS + + CLEAR_FILTERS_INDEPENDENCE_TESTS + + CLEAR_FILTERS_CAPPED_TESTS + + CLEAR_FILTERS_CLUSTERED_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CLEAR_FILTERS_CORE_TESTS)) +def test_planCacheClearFilters_core(database_client, collection, test): + """Test planCacheClearFilters command core 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, + ) + + +# Property [Repeated Clear All]: Running planCacheClearFilters multiple times +# in succession without setting any filters always succeeds. +def test_planCacheClearFilters_repeated_clear_all(collection): + """Test planCacheClearFilters succeeds when called multiple times in succession.""" + for i in range(3): + result = execute_command(collection, {"planCacheClearFilters": collection.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg=f"planCacheClearFilters call {i + 1} of 3 should succeed", + ) diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_edge_cases.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_edge_cases.py new file mode 100644 index 000000000..425d5a783 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_edge_cases.py @@ -0,0 +1,209 @@ +"""Tests for planCacheClearFilters command edge cases and permissiveness.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.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.target_collection import NamedCollection + +# Property [Unknown Fields Accepted]: planCacheClearFilters silently accepts +# unrecognized fields without error. +CLEAR_FILTERS_UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field_foo", + command=lambda ctx: {"planCacheClearFilters": ctx.collection, "foo": "bar"}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should silently accept unknown field", + ), + CommandTestCase( + "case_variation_Query", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "Query": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat capitalized Query as unknown field", + ), + CommandTestCase( + "case_variation_Sort", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "Sort": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat capitalized Sort as unknown field", + ), + CommandTestCase( + "case_variation_Projection", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "Projection": {"a": 1}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should treat capitalized Projection as unknown field", + ), +] + +# Property [Collation Valid]: planCacheClearFilters accepts valid collation +# with query. +CLEAR_FILTERS_COLLATION_VALID_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_locale_en", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "collation": {"locale": "en"}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept collation with locale en", + ), + CommandTestCase( + "collation_locale_fr_strength_2", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "collation": {"locale": "fr", "strength": 2}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept collation with locale and strength", + ), +] + +# Property [Collation Permissiveness]: collation accepts values that would +# normally be invalid because MongoDB silently accepts them in this command. +CLEAR_FILTERS_COLLATION_PERMISSIVE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_empty_document", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": {}, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept empty collation document", + ), + CommandTestCase( + "collation_string", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": "en", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept string collation", + ), + CommandTestCase( + "collation_int", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": 123, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept int collation", + ), + CommandTestCase( + "collation_bool", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": True, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept bool collation", + ), + CommandTestCase( + "collation_array", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": [1], + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept array collation", + ), + CommandTestCase( + "collation_null", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "collation": None, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should accept null collation", + ), +] + +# Property [Collection Name Edge Cases]: planCacheClearFilters succeeds with +# special characters, unicode, and long collection names. +CLEAR_FILTERS_NAME_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_long", + target_collection=NamedCollection(suffix="_" + "a" * 150), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with a long collection name", + ), + CommandTestCase( + "name_hyphen", + target_collection=NamedCollection(suffix="_my-coll"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with hyphen in name", + ), + CommandTestCase( + "name_unicode", + target_collection=NamedCollection(suffix="_\u00e9"), + docs=[{"_id": 1}], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with unicode name", + ), +] + +# Property [Comment Edge Cases]: planCacheClearFilters succeeds with edge-case +# comment values. +CLEAR_FILTERS_COMMENT_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "comment_long", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "comment": "x" * 10_000, + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with very long comment", + ), + CommandTestCase( + "comment_empty_string", + command=lambda ctx: { + "planCacheClearFilters": ctx.collection, + "comment": "", + }, + expected={"ok": 1.0}, + msg="planCacheClearFilters should succeed with empty string comment", + ), +] + +CLEAR_FILTERS_EDGE_CASE_TESTS: list[CommandTestCase] = ( + CLEAR_FILTERS_UNKNOWN_FIELD_TESTS + + CLEAR_FILTERS_COLLATION_VALID_TESTS + + CLEAR_FILTERS_COLLATION_PERMISSIVE_TESTS + + CLEAR_FILTERS_NAME_EDGE_CASE_TESTS + + CLEAR_FILTERS_COMMENT_EDGE_CASE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CLEAR_FILTERS_EDGE_CASE_TESTS)) +def test_planCacheClearFilters_edge_cases(database_client, collection, test): + """Test planCacheClearFilters command edge cases and permissiveness.""" + 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/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_errors.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_errors.py new file mode 100644 index 000000000..3a7feb02c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_errors.py @@ -0,0 +1,192 @@ +"""Tests for planCacheClearFilters command error cases.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + 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 ( + TimeseriesCollection, + ViewCollection, +) + +# Property [View Rejection]: planCacheClearFilters is not supported on views. +CLEAR_FILTERS_VIEW_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_rejection", + target_collection=ViewCollection(), + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="planCacheClearFilters should be rejected on a view", + ), +] + +# Property [Timeseries Rejection]: planCacheClearFilters is not supported on +# timeseries collections. +CLEAR_FILTERS_TIMESERIES_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_rejection", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"planCacheClearFilters": ctx.collection}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="planCacheClearFilters should be rejected on a timeseries collection", + ), +] + +# Property [Field Type Rejection]: all non-string BSON types for the +# planCacheClearFilters field produce an invalid namespace error. +CLEAR_FILTERS_NAME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_type_int32", + command={"planCacheClearFilters": 123}, + error_code=INVALID_NAMESPACE_ERROR, + msg="int32 collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_int64", + command={"planCacheClearFilters": Int64(1)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Int64 collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_double", + command={"planCacheClearFilters": 1.5}, + error_code=INVALID_NAMESPACE_ERROR, + msg="double collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_decimal128", + command={"planCacheClearFilters": Decimal128("1")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Decimal128 collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_bool_true", + command={"planCacheClearFilters": True}, + error_code=INVALID_NAMESPACE_ERROR, + msg="bool true collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_bool_false", + command={"planCacheClearFilters": False}, + error_code=INVALID_NAMESPACE_ERROR, + msg="bool false collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_null", + command={"planCacheClearFilters": None}, + error_code=INVALID_NAMESPACE_ERROR, + msg="null collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_array", + command={"planCacheClearFilters": []}, + error_code=INVALID_NAMESPACE_ERROR, + msg="array collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_object", + command={"planCacheClearFilters": {}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="object collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_objectid", + command={"planCacheClearFilters": ObjectId()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="ObjectId collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_binary", + command={"planCacheClearFilters": Binary(b"\x00")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Binary collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_datetime", + command={"planCacheClearFilters": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="datetime collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_timestamp", + command={"planCacheClearFilters": Timestamp(0, 0)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Timestamp collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_regex", + command={"planCacheClearFilters": Regex(".*")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Regex collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_code", + command={"planCacheClearFilters": Code("function(){}")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Code collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_minkey", + command={"planCacheClearFilters": MinKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="MinKey collection name should be rejected as invalid type", + ), + CommandTestCase( + "name_type_maxkey", + command={"planCacheClearFilters": MaxKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="MaxKey collection name should be rejected as invalid type", + ), +] + +# Property [Namespace Edge Cases]: empty string and null byte collection +# names produce an invalid namespace error. +CLEAR_FILTERS_NAME_EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_empty_string", + command={"planCacheClearFilters": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Empty string collection name should be rejected", + ), + CommandTestCase( + "name_null_byte_embedded", + command={"planCacheClearFilters": "test\x00coll"}, + error_code=INVALID_NAMESPACE_ERROR, + msg="Null byte embedded in collection name should be rejected", + ), +] + +CLEAR_FILTERS_ERROR_TESTS: list[CommandTestCase] = ( + CLEAR_FILTERS_VIEW_REJECTION_TESTS + + CLEAR_FILTERS_TIMESERIES_REJECTION_TESTS + + CLEAR_FILTERS_NAME_TYPE_ERROR_TESTS + + CLEAR_FILTERS_NAME_EDGE_CASE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CLEAR_FILTERS_ERROR_TESTS)) +def test_planCacheClearFilters_errors(database_client, collection, test): + """Test planCacheClearFilters command error cases.""" + 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/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_field_types.py b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_field_types.py new file mode 100644 index 000000000..4ece4be83 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_planCacheClearFilters_field_types.py @@ -0,0 +1,117 @@ +"""Tests for planCacheClearFilters command field type acceptance.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +_BSON_TYPE_VALUES = [ + ("document", {"a": 1}), + ("empty_document", {}), + ("string", "hello"), + ("int32", 123), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, 2]), + ("binary", Binary(b"\x00")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(0, 0)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), +] + +# Property [Query Type Acceptance]: the query field accepts all BSON types +# without type validation. +CLEAR_FILTERS_QUERY_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"query_{tid}", + command=lambda ctx, v=val: {"planCacheClearFilters": ctx.collection, "query": v}, + expected={"ok": 1.0}, + msg=f"query={tid} should be accepted", + ) + for tid, val in _BSON_TYPE_VALUES +] + +# Property [Sort Type Acceptance]: the sort field accepts all BSON types +# without type validation. +CLEAR_FILTERS_SORT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"sort_{tid}", + command=lambda ctx, v=val: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "sort": v, + }, + expected={"ok": 1.0}, + msg=f"sort={tid} should be accepted", + ) + for tid, val in _BSON_TYPE_VALUES +] + +# Property [Projection Type Acceptance]: the projection field accepts all +# BSON types without type validation. +CLEAR_FILTERS_PROJECTION_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"projection_{tid}", + command=lambda ctx, v=val: { + "planCacheClearFilters": ctx.collection, + "query": {"a": 1}, + "projection": v, + }, + expected={"ok": 1.0}, + msg=f"projection={tid} should be accepted", + ) + for tid, val in _BSON_TYPE_VALUES +] + +# Property [Comment Type Acceptance]: the comment field accepts any valid +# BSON type. +CLEAR_FILTERS_COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command=lambda ctx, v=val: { + "planCacheClearFilters": ctx.collection, + "comment": v, + }, + expected={"ok": 1.0}, + msg=f"comment={tid} should be accepted", + ) + for tid, val in _BSON_TYPE_VALUES +] + +CLEAR_FILTERS_FIELD_TYPE_TESTS: list[CommandTestCase] = ( + CLEAR_FILTERS_QUERY_TYPE_TESTS + + CLEAR_FILTERS_SORT_TYPE_TESTS + + CLEAR_FILTERS_PROJECTION_TYPE_TESTS + + CLEAR_FILTERS_COMMENT_TYPE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CLEAR_FILTERS_FIELD_TYPE_TESTS)) +def test_planCacheClearFilters_field_types(database_client, collection, test): + """Test planCacheClearFilters command field type 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, + ) From 81b942b406279c5354425462190abb4fd02c0238 Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Wed, 1 Jul 2026 17:31:29 -0700 Subject: [PATCH 16/51] Add update array tests for $push (#598) Signed-off-by: Victor Tsang --- .../operator/update/array/push/__init__.py | 0 .../push/test_push_bson_type_validation.py | 95 ++++++++ .../update/array/push/test_push_commands.py | 139 ++++++++++++ .../update/array/push/test_push_core.py | 204 ++++++++++++++++++ .../update/array/push/test_push_errors.py | 23 ++ .../test_update_modifier_integration.py | 8 + 6 files changed, 469 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/__init__.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py new file mode 100644 index 000000000..bb0b290c0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py @@ -0,0 +1,95 @@ +"""Tests for $push BSON type validation. + +Verifies that $push rejects non-array target field types with error code 2, +and can push any BSON type as a value into an array. +""" + +import pytest +from bson import Binary + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command + +PUSH_PARAMS = [ + BsonTypeTestCase( + id="target_field", + msg="$push should reject non-array target field types", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": [1, 2, 99]}], + valid_inputs={BsonType.ARRAY: [1, 2]}, + ), + BsonTypeTestCase( + id="value_element", + msg="$push should accept any BSON type as value to push", + valid_types=list(BsonType), + default_error_code=BAD_VALUE_ERROR, + valid_inputs={BsonType.BIN_DATA: Binary(b"\x00\x01\x02", 128)}, + ), +] + + +def _setup_doc(spec, sample_value) -> dict: + """Build the setup document based on which aspect is being tested.""" + if spec.id == "target_field": + return {"_id": 1, "arr": sample_value} + return {"_id": 1, "arr": []} + + +def _build_update(spec, sample_value) -> dict: + """Build the update command based on which aspect is being tested.""" + if spec.id == "target_field": + return {"$push": {"arr": 99}} + return {"$push": {"arr": sample_value}} + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(PUSH_PARAMS) +) +def test_push_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test $push rejects non-array target field types with error.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"$push should reject {bson_type.value} for {spec.id}", + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(PUSH_PARAMS) +) +def test_push_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test $push accepts valid BSON types for target field and value element.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + if spec.id == "target_field": + expected = spec.expected + else: + expected = [{"_id": 1, "arr": [sample_value]}] + assertSuccess(result, expected, msg=f"$push should accept {bson_type.value} for {spec.id}") diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py new file mode 100644 index 000000000..ba4babb9f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py @@ -0,0 +1,139 @@ +"""Tests for $push update command behavior. + +Covers: updateOne, updateMany, upsert, bulkWrite. +""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command + + +def test_push_updateOne_response(collection): + """Test $push with updateOne reports correct response.""" + collection.insert_many([{"q": 1, "a": [1, 2, 3]}, {"q": 1, "a": [1, 2, 3]}]) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"q": 1}, "u": {"$push": {"a": 4}}}], + }, + ) + assertSuccessPartial( + result, {"n": 1, "nModified": 1, "ok": 1.0}, msg="updateOne should report nModified=1" + ) + + +def test_push_updateOne_result(collection): + """Test $push with updateOne produces correct document.""" + collection.insert_one({"_id": 1, "arr": [1, 2, 3]}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$push": {"arr": 4}}}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "arr": [1, 2, 3, 4]}], msg="updateOne should append value") + + +def test_push_updateMany(collection): + """Test $push with updateMany updates all matched docs.""" + collection.insert_many( + [{"_id": 1, "arr": [1]}, {"_id": 2, "arr": [10]}, {"_id": 3, "arr": [100]}] + ) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$push": {"arr": 99}}, "multi": True}], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [ + {"_id": 1, "arr": [1, 99]}, + {"_id": 2, "arr": [10, 99]}, + {"_id": 3, "arr": [100, 99]}, + ], + msg="updateMany should push to each matched document", + ) + + +def test_push_upsert_creates_doc(collection): + """Test $push with upsert:true creates document when not found.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 99}, "u": {"$push": {"arr": 5}}, "upsert": True}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 99}}) + assertSuccess( + result, + [{"_id": 99, "arr": [5]}], + msg="Upsert should create doc with array containing the value", + ) + + +def test_push_upsert_with_each(collection): + """Test $push with $each and upsert:true creates document with all values.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 99}, "u": {"$push": {"arr": {"$each": [1, 2, 3]}}}, "upsert": True} + ], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 99}}) + assertSuccess( + result, + [{"_id": 99, "arr": [1, 2, 3]}], + msg="Upsert with $each should create doc with all values", + ) + + +def test_push_bulk_write_response(collection): + """Test $push in bulkWrite reports correct response.""" + collection.insert_many([{"_id": 1, "arr": [1]}, {"_id": 2, "arr": [10]}]) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$push": {"arr": 2}}}, + {"q": {"_id": 2}, "u": {"$push": {"arr": 20}}}, + ], + }, + ) + assertSuccessPartial( + result, {"n": 2, "nModified": 2, "ok": 1.0}, msg="Bulk update should report nModified=2" + ) + + +def test_push_bulk_write_result(collection): + """Test $push in bulkWrite produces correct documents.""" + collection.insert_many([{"_id": 1, "arr": [1]}, {"_id": 2, "arr": [10]}]) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$push": {"arr": 2}}}, + {"q": {"_id": 2}, "u": {"$push": {"arr": 20}}}, + ], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [{"_id": 1, "arr": [1, 2]}, {"_id": 2, "arr": [10, 20]}], + msg="Bulk update should push to each doc", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py new file mode 100644 index 000000000..667fd1a40 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py @@ -0,0 +1,204 @@ +"""Tests for $push core behavior. + +Covers: basic append, missing field creation, array as value, empty operand, +dot notation/nested fields, multiple fields, large arrays, $sort without $each literal. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +CORE_BEHAVIOR_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "append_to_existing_array", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {"arr": 4}}, + expected={"_id": 1, "arr": [1, 2, 3, 4]}, + msg="$push should append value to end of existing array", + ), + UpdateTestCase( + "append_to_empty_array", + setup_docs=[{"_id": 1, "arr": []}], + query={"_id": 1}, + update={"$push": {"arr": "first"}}, + expected={"_id": 1, "arr": ["first"]}, + msg="$push on empty array should add as first element", + ), + UpdateTestCase( + "missing_field_creates_array", + setup_docs=[{"_id": 1, "x": 1}], + query={"_id": 1}, + update={"$push": {"arr": 42}}, + expected={"_id": 1, "x": 1, "arr": [42]}, + msg="$push on missing field should create array and preserve existing fields", + ), + UpdateTestCase( + "array_value_appended_as_single_element", + setup_docs=[{"_id": 1, "arr": [1, 2]}], + query={"_id": 1}, + update={"$push": {"arr": [3, 4]}}, + expected={"_id": 1, "arr": [1, 2, [3, 4]]}, + msg="$push with array value should append entire array as single element", + ), + UpdateTestCase( + "nested_array_value", + setup_docs=[{"_id": 1, "arr": []}], + query={"_id": 1}, + update={"$push": {"arr": [[1, 2], [3, 4]]}}, + expected={"_id": 1, "arr": [[[1, 2], [3, 4]]]}, + msg="$push with nested array appends the whole nested array as one element", + ), + UpdateTestCase( + "object_value", + setup_docs=[{"_id": 1, "arr": []}], + query={"_id": 1}, + update={"$push": {"arr": {"name": "test", "val": 99}}}, + expected={"_id": 1, "arr": [{"name": "test", "val": 99}]}, + msg="$push with object value should append object to array", + ), + UpdateTestCase( + "duplicate_value_allowed", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {"arr": 2}}, + expected={"_id": 1, "arr": [1, 2, 3, 2]}, + msg="$push should allow duplicate values", + ), + UpdateTestCase( + "empty_operand_noop", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {}}, + expected={"_id": 1, "arr": [1, 2, 3]}, + msg="$push with empty operand {} is a no-op", + ), +] + + +NESTED_FIELD_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "dot_notation_nested_array", + setup_docs=[{"_id": 1, "a": {"b": [1, 2]}}], + query={"_id": 1}, + update={"$push": {"a.b": 3}}, + expected={"_id": 1, "a": {"b": [1, 2, 3]}}, + msg="$push on nested array using dot notation", + ), + UpdateTestCase( + "dot_notation_deeply_nested", + setup_docs=[{"_id": 1, "a": {"b": {"c": {"d": [10]}}}}], + query={"_id": 1}, + update={"$push": {"a.b.c.d": 20}}, + expected={"_id": 1, "a": {"b": {"c": {"d": [10, 20]}}}}, + msg="$push on deeply nested array using dot notation", + ), + UpdateTestCase( + "dot_notation_creates_nested_path", + setup_docs=[{"_id": 1}], + query={"_id": 1}, + update={"$push": {"a.b": "val"}}, + expected={"_id": 1, "a": {"b": ["val"]}}, + msg="$push on missing nested field creates structure", + ), + UpdateTestCase( + "array_within_embedded_doc", + setup_docs=[{"_id": 1, "obj": {"items": ["x"]}}], + query={"_id": 1}, + update={"$push": {"obj.items": "y"}}, + expected={"_id": 1, "obj": {"items": ["x", "y"]}}, + msg="$push on array within embedded document", + ), + UpdateTestCase( + "numeric_index_path", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2]}, {"b": [3]}]}], + query={"_id": 1}, + update={"$push": {"a.0.b": 99}}, + expected={"_id": 1, "a": [{"b": [1, 2, 99]}, {"b": [3]}]}, + msg="$push on a.0.b where a is an array uses numeric index", + ), +] + + +MULTIPLE_FIELD_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "push_multiple_fields", + setup_docs=[{"_id": 1, "a": [1], "b": [10]}], + query={"_id": 1}, + update={"$push": {"a": 2, "b": 20}}, + expected={"_id": 1, "a": [1, 2], "b": [10, 20]}, + msg="$push on multiple array fields in single operation", + ), + UpdateTestCase( + "push_multiple_fields_independent", + setup_docs=[{"_id": 1, "x": ["a"], "y": [1], "z": [True]}], + query={"_id": 1}, + update={"$push": {"x": "b", "y": 2, "z": False}}, + expected={"_id": 1, "x": ["a", "b"], "y": [1, 2], "z": [True, False]}, + msg="$push on multiple fields should process each independently", + ), + UpdateTestCase( + "push_multiple_fields_with_modifiers", + setup_docs=[{"_id": 1, "a": [3, 1, 2], "b": [10, 20, 30]}], + query={"_id": 1}, + update={ + "$push": { + "a": {"$each": [4], "$sort": 1}, + "b": {"$each": [0], "$position": 0}, + } + }, + expected={"_id": 1, "a": [1, 2, 3, 4], "b": [0, 10, 20, 30]}, + msg="$push multiple fields each with their own modifiers", + ), +] + + +LARGE_ARRAY_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "push_to_large_array", + setup_docs=[{"_id": 1, "arr": list(range(1000))}], + query={"_id": 1}, + update={"$push": {"arr": 1000}}, + expected={"_id": 1, "arr": list(range(1001))}, + msg="$push should append to large array correctly", + ), +] + + +MODIFIER_WITHOUT_EACH_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "sort_without_each_pushes_literal", + setup_docs=[{"_id": 1, "arr": [3, 1, 2]}], + query={"_id": 1}, + update={"$push": {"arr": {"$sort": 1}}}, + expected={"_id": 1, "arr": [3, 1, 2, {"$sort": 1}]}, + msg="$sort without $each pushes the object as literal value", + ), +] + + +ALL_TESTS = ( + CORE_BEHAVIOR_TESTS + + NESTED_FIELD_TESTS + + MULTIPLE_FIELD_TESTS + + LARGE_ARRAY_TESTS + + MODIFIER_WITHOUT_EACH_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_push_core(collection, test: UpdateTestCase): + """Test $push core behavior produces expected document.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": test.query, "u": test.update}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py new file mode 100644 index 000000000..c7dc2667e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py @@ -0,0 +1,23 @@ +"""Tests for $push error handling. + +Covers: path traversal errors specific to $push. +""" + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import PATH_NOT_VIABLE_ERROR +from documentdb_tests.framework.executor import execute_command + + +def test_push_scalar_intermediate_path_error(collection): + """Test $push through scalar intermediate field should error.""" + collection.insert_one({"_id": 1, "a": 5}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$push": {"a.b": 1}}}], + }, + ) + assertFailureCode( + result, PATH_NOT_VIABLE_ERROR, msg="$push through scalar intermediate field should error" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py b/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py index 979d58c47..ff2401596 100644 --- a/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py +++ b/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py @@ -337,6 +337,14 @@ def test_position_update_modifier_integration(collection, test_case): expected=[{"_id": 1, "arr": [3, 2, 1]}], msg="Sort descending with negative slice should keep last 3 of sorted desc array", ), + UpdateTestCase( + id="sort_slice_removes_new_elements", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {"arr": {"$each": [10, 20], "$sort": 1, "$slice": 3}}}, + expected=[{"_id": 1, "arr": [1, 2, 3]}], + msg="$sort + $slice where slice removes newly added elements", + ), ] From 8e01541966104f9bcc8af76c165e608593450108 Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:47:41 -0700 Subject: [PATCH 17/51] Add listCommands tests (#609) Signed-off-by: PatersonProjects --- .../buildInfo/test_buildInfo_consistency.py | 12 +- .../commands/listCommands/__init__.py | 0 .../test_listCommands_argument_handling.py | 64 +++++++++ .../test_listCommands_consistency.py | 75 +++++++++++ .../test_listCommands_error_conditions.py | 47 +++++++ .../test_listCommands_response_structure.py | 124 ++++++++++++++++++ 6 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py index 843ef82dc..8b84638d2 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py @@ -38,10 +38,8 @@ def test_buildInfo_same_result_any_database(collection): def test_buildInfo_nonexistent_database(collection): - """Test buildInfo succeeds regardless of database context.""" - result = execute_admin_command(collection, {"buildInfo": 1}) - assertSuccessPartial( - result, - {"ok": 1.0}, - msg="Should succeed regardless of database existence", - ) + """Test buildInfo succeeds when run on a non-existent database.""" + other_db = f"{collection.name}_nonexistent_db" + other_col = collection.database.client[other_db][collection.name] + result = execute_command(other_col, {"buildInfo": 1}) + assertSuccessPartial(result, {"ok": 1.0}, msg="Should succeed on non-existent database") diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_argument_handling.py new file mode 100644 index 000000000..6be29a5c9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_argument_handling.py @@ -0,0 +1,64 @@ +"""Tests for listCommands command argument handling. + +Validates that listCommands accepts any BSON type as its argument value +and rejects unrecognized fields. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +# listCommands accepts all BSON types as its argument value +LISTCOMMANDS_BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="argument", + msg="listCommands should accept any BSON type as argument", + keyword="listCommands", + valid_types=[ + BsonType.DOUBLE, + BsonType.STRING, + BsonType.OBJECT, + BsonType.ARRAY, + BsonType.BIN_DATA, + BsonType.OBJECT_ID, + BsonType.BOOL, + BsonType.DATE, + BsonType.NULL, + BsonType.REGEX, + BsonType.JAVASCRIPT, + BsonType.INT, + BsonType.TIMESTAMP, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.MIN_KEY, + BsonType.MAX_KEY, + ], + ), +] + +ACCEPTANCE_TESTS = generate_bson_acceptance_test_cases(LISTCOMMANDS_BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_TESTS) +def test_listCommands_argument_types(collection, bson_type, sample_value, spec): + """Test that listCommands accepts any BSON type as argument value.""" + result = execute_admin_command(collection, {"listCommands": sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=f"{spec.msg} - {bson_type.value}", raw_res=True) + + +def test_listCommands_extra_fields_ignored(collection): + """Test that listCommands ignores extra unrecognized fields.""" + result = execute_admin_command(collection, {"listCommands": 1, "unknownField": 1}) + assertProperties( + result, {"ok": Eq(1.0)}, msg="Should succeed even with extra fields", raw_res=True + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_consistency.py new file mode 100644 index 000000000..1847d0a47 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_consistency.py @@ -0,0 +1,75 @@ +"""Tests for listCommands command consistency and availability. + +Validates that listCommands works across databases and returns consistent results. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import ( + assertProperties, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +DATABASE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="admin_database", + command={"listCommands": 1}, + use_admin=True, + checks={"ok": Eq(1.0)}, + msg="Should succeed on admin database", + ), + DiagnosticTestCase( + id="non_admin_database", + command={"listCommands": 1}, + use_admin=False, + checks={"ok": Eq(1.0)}, + msg="Should succeed on non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DATABASE_TESTS)) +def test_listCommands_database_availability(collection, test): + """Test listCommands works on both admin and non-admin databases.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_listCommands_nonexistent_database(collection): + """Test listCommands succeeds when run on a non-existent database.""" + other_db = f"{collection.name}_nonexistent_db" + other_col = collection.database.client[other_db][collection.name] + result = execute_command(other_col, {"listCommands": 1}) + assertSuccessPartial(result, {"ok": 1.0}, msg="Should succeed on non-existent database") + + +def test_listCommands_idempotent(collection): + """Test calling listCommands multiple times returns identical results.""" + result1 = execute_admin_command(collection, {"listCommands": 1}) + result2 = execute_admin_command(collection, {"listCommands": 1}) + assertSuccess(result2, expected=result1, msg="Should return identical results", raw_res=True) + + +def test_listCommands_same_result_any_database(collection): + """Test listCommands returns same result from admin and non-admin database.""" + admin_result = execute_admin_command(collection, {"listCommands": 1}) + db_result = execute_command(collection, {"listCommands": 1}) + assertSuccess( + db_result, + expected=admin_result, + msg="Should return same result from any database", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_error_conditions.py new file mode 100644 index 000000000..e16cf625e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_error_conditions.py @@ -0,0 +1,47 @@ +"""Tests for listCommands command error conditions. + +Validates that invalid usages of listCommands produce appropriate errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_FOUND_ERROR, + UNKNOWN_PIPELINE_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="as_aggregation_stage", + command={"aggregate": "test", "pipeline": [{"$listCommands": {}}], "cursor": {}}, + use_admin=False, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="$listCommands is not a valid aggregation stage", + ), + DiagnosticTestCase( + id="case_sensitive", + command={"ListCommands": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Case-mismatched command name should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_listCommands_error_conditions(collection, test): + """Verifies listCommands rejects invalid usages with appropriate error codes.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_response_structure.py new file mode 100644 index 000000000..0813e84fa --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/listCommands/test_listCommands_response_structure.py @@ -0,0 +1,124 @@ +"""Tests for listCommands command response structure. + +Validates the response format, field types, and presence of expected commands. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType + +pytestmark = pytest.mark.admin + + +RESPONSE_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="ok_is_1", + checks={"ok": Eq(1.0)}, + msg="'ok' field should be 1.0", + ), + DiagnosticTestCase( + id="commands_is_object", + checks={"commands": IsType("object")}, + msg="'commands' field should be an object", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_FIELD_TESTS)) +def test_listCommands_response_fields(collection, test): + """Verify listCommands response contains expected fields with correct types.""" + result = execute_admin_command(collection, {"listCommands": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +COMMAND_ENTRY_STRUCTURE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="find_has_help", + checks={"commands.find.help": IsType("string")}, + msg="Command entry 'find' should have 'help' field of type string", + ), + DiagnosticTestCase( + id="find_has_adminOnly", + checks={"commands.find.adminOnly": IsType("bool")}, + msg="Command entry 'find' should have 'adminOnly' field of type boolean", + ), + DiagnosticTestCase( + id="find_has_requiresAuth", + checks={"commands.find.requiresAuth": IsType("bool")}, + msg="Command entry 'find' should have 'requiresAuth' field of type boolean", + ), + DiagnosticTestCase( + id="find_has_secondaryOk", + checks={"commands.find.secondaryOk": IsType("bool")}, + msg="Command entry 'find' should have 'secondaryOk' field of type boolean", + ), + DiagnosticTestCase( + id="find_has_apiVersions", + checks={"commands.find.apiVersions": IsType("array")}, + msg="Command entry 'find' should have 'apiVersions' field of type array", + ), + DiagnosticTestCase( + id="find_has_deprecatedApiVersions", + checks={"commands.find.deprecatedApiVersions": IsType("array")}, + msg="Command entry 'find' should have 'deprecatedApiVersions' field of type array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COMMAND_ENTRY_STRUCTURE_TESTS)) +def test_listCommands_command_entry_structure(collection, test): + """Verify each command entry contains expected fields with correct types.""" + result = execute_admin_command(collection, {"listCommands": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +KNOWN_COMMANDS_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="find_present", + checks={"commands.find": Exists()}, + msg="'find' should be listed", + ), + DiagnosticTestCase( + id="insert_present", + checks={"commands.insert": Exists()}, + msg="'insert' should be listed", + ), + DiagnosticTestCase( + id="update_present", + checks={"commands.update": Exists()}, + msg="'update' should be listed", + ), + DiagnosticTestCase( + id="delete_present", + checks={"commands.delete": Exists()}, + msg="'delete' should be listed", + ), + DiagnosticTestCase( + id="aggregate_present", + checks={"commands.aggregate": Exists()}, + msg="'aggregate' should be listed", + ), + DiagnosticTestCase( + id="ping_present", + checks={"commands.ping": Exists()}, + msg="'ping' should be listed", + ), + DiagnosticTestCase( + id="listCommands_present", + checks={"commands.listCommands": Exists()}, + msg="'listCommands' should be listed in its own output", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(KNOWN_COMMANDS_TESTS)) +def test_listCommands_known_commands_present(collection, test): + """Verify well-known commands appear in the listCommands response.""" + result = execute_admin_command(collection, {"listCommands": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) From a1737eb3399655b43260112c4c23c00a08d0737d Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:51:59 -0700 Subject: [PATCH 18/51] Add lockInfo tests (#612) Signed-off-by: PatersonProjects --- .../diagnostic/commands/lockInfo/__init__.py | 1 + .../test_lockInfo_argument_validation.py | 78 +++++++++++++++++++ .../commands/lockInfo/test_lockInfo_errors.py | 56 +++++++++++++ .../test_lockInfo_response_structure.py | 64 +++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/__init__.py new file mode 100644 index 000000000..49a4f47a0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/__init__.py @@ -0,0 +1 @@ +"""Tests for the lockInfo diagnostic command.""" diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_argument_validation.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_argument_validation.py new file mode 100644 index 000000000..585d7576b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_argument_validation.py @@ -0,0 +1,78 @@ +"""Tests for lockInfo command argument validation. + +Verifies that lockInfo accepts any BSON type for the command field value and +that successive calls succeed (point-in-time snapshot behaviour). +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import FLOAT_INFINITY + +pytestmark = pytest.mark.admin + + +LOCKINFO_BSON_TYPE_SPECS = [ + BsonTypeTestCase( + id="command_field", + msg="lockInfo accepts any BSON type for the command field", + keyword="lockInfo", + valid_types=list(BsonType), + ) +] + +ACCEPTANCE_TESTS = generate_bson_acceptance_test_cases(LOCKINFO_BSON_TYPE_SPECS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_TESTS) +def test_lockInfo_accepts_any_type(collection, bson_type, sample_value, spec): + """Verify lockInfo succeeds when the command field value is a given BSON type.""" + result = execute_admin_command(collection, {"lockInfo": sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=spec.msg, raw_res=True) + + +VALUE_EDGE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="int_zero", + command={"lockInfo": 0}, + checks={"ok": Eq(1.0)}, + msg="lockInfo should accept int 0", + ), + DiagnosticTestCase( + id="int_neg1", + command={"lockInfo": -1}, + checks={"ok": Eq(1.0)}, + msg="lockInfo should accept int -1", + ), + DiagnosticTestCase( + id="float_infinity", + command={"lockInfo": FLOAT_INFINITY}, + checks={"ok": Eq(1.0)}, + msg="lockInfo should accept float infinity as the command field value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VALUE_EDGE_TESTS)) +def test_lockInfo_accepts_value_edge_cases(collection, test): + """Verify lockInfo accepts int and float edge values for the command field.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_lockInfo_point_in_time_snapshot(collection): + """Test lockInfo result is a point-in-time snapshot (successive calls succeed).""" + execute_admin_command(collection, {"lockInfo": 1}) + result = execute_admin_command(collection, {"lockInfo": 1}) + assertProperties(result, {"ok": Eq(1.0)}, msg="Successive calls should succeed", raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_errors.py new file mode 100644 index 000000000..391c211d1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_errors.py @@ -0,0 +1,56 @@ +"""Tests for lockInfo command error cases. + +Verifies that lockInfo returns correct error codes when run on non-admin +database or with unrecognized fields. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_FOUND_ERROR, + UNAUTHORIZED_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="non_admin_database", + command={"lockInfo": 1}, + use_admin=False, + error_code=UNAUTHORIZED_ERROR, + msg="lockInfo on non-admin db should be unauthorized", + ), + DiagnosticTestCase( + id="unrecognized_field", + command={"lockInfo": 1, "foo": 1}, + use_admin=True, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Unrecognized field should error", + ), + DiagnosticTestCase( + id="wrong_case", + command={"LockInfo": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Command name is case-sensitive; 'LockInfo' should not be found", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_lockInfo_error_conditions(collection, test): + """Verifies lockInfo returns appropriate error codes for invalid usages.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_response_structure.py new file mode 100644 index 000000000..42e27eabf --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/lockInfo/test_lockInfo_response_structure.py @@ -0,0 +1,64 @@ +"""Tests for lockInfo command response structure. + +Verifies the top-level structure of the lockInfo response: ok field and +lockInfo array field. Also verifies the structure of individual lock entries +using an fsync lock to guarantee a non-empty snapshot. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +RESPONSE_STRUCTURE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "ok_field", + checks={"ok": Eq(1.0)}, + msg="Response should contain ok: 1.0", + ), + DiagnosticTestCase( + "lockInfo_is_array", + checks={"lockInfo": IsType("array")}, + msg="lockInfo field should be an array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_STRUCTURE_TESTS)) +def test_lockInfo_response_structure(collection, test): + """Verify lockInfo response contains expected fields with correct types.""" + result = execute_admin_command(collection, {"lockInfo": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_lockInfo_entry_structure(collection): + """Test a lock entry exposes resourceId (string), granted (array), and pending (array). + + Acquires a global fsync lock so the lock manager reports at least one entry, + guaranteeing the assertion runs against a real lock entry rather than an + empty snapshot. Must not run in parallel — fsyncLock is a global server lock. + """ + execute_admin_command(collection, {"fsync": 1, "lock": True}) + try: + result = execute_admin_command(collection, {"lockInfo": 1}) + entry = result["lockInfo"][0] + finally: + execute_admin_command(collection, {"fsyncUnlock": 1}) + assertProperties( + entry, + { + "resourceId": IsType("string"), + "granted": IsType("array"), + "pending": IsType("array"), + }, + msg="lock entry should expose resourceId (string), granted (array), pending (array)", + raw_res=True, + ) From 13632cbc7bf91bb78c99d8f7269a053159dabbe0 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:02:09 -0700 Subject: [PATCH 19/51] Add `removeQuerySettings` tests (#613) Signed-off-by: Alina (Xi) Li --- .../commands/removeQuerySettings/__init__.py | 0 .../test_removeQuerySettings_behavior.py | 584 ++++++++++++++++++ .../test_removeQuerySettings_core.py | 311 ++++++++++ .../test_removeQuerySettings_error.py | 212 +++++++ 4 files changed, 1107 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py create mode 100644 documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py new file mode 100644 index 000000000..e98715a80 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py @@ -0,0 +1,584 @@ +"""Tests for removeQuerySettings command behavioral verification. + +Verifies that removeQuerySettings actually removes query settings from the +cluster, not just that it returns ok: 1.0. Uses $querySettings to observe +settings state before and after removal. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Remove By Query Shape]: removeQuerySettings removes settings +# when given the original query shape, verified via $querySettings. +# Property [Remove By Hash]: removeQuerySettings removes settings when given +# the query shape hash string, verified via $querySettings. +# Property [Remove Distinct Shape]: removeQuerySettings removes settings for +# distinct query shapes, verified via $querySettings. +# Property [Remove Aggregate Shape]: removeQuerySettings removes settings for +# aggregate query shapes, verified via $querySettings. +# Property [Shape Matching Ignores Filter Values]: query shape matching uses +# field structure, not values. Removing with different filter values removes +# the original setting. +REMOVEQUERYSETTINGS_SETTING_REMOVED_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "removes_by_query_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"r1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r1": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r1": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the setting by query shape", + ), + SettingsTestCase( + "removes_by_hash", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"r2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: {"removeQuerySettings": ctx.setup_results[0]["queryShapeHash"]}, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r2": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the setting by hash", + ), + SettingsTestCase( + "removes_distinct_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the distinct setting", + ), + SettingsTestCase( + "removes_aggregate_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the aggregate setting", + ), + SettingsTestCase( + "shape_ignores_filter_values", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm1": 999}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm1": 1}, + "$db": ctx.database, + } + } + ], + msg="shape matching should ignore filter values and remove the setting", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_SETTING_REMOVED_TESTS)) +def test_removeQuerySettings_setting_removed(collection, test): + """Test that removeQuerySettings actually removes settings, verified via $querySettings.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + expected_hash = ctx.setup_results[0]["queryShapeHash"] + + execute_admin_command(collection, test.build_command(ctx)) + + admin = collection.database.client.admin + qs_result = admin.command( + {"aggregate": 1, "pipeline": [{"$querySettings": {}}], "cursor": {}} + ) + batch: list[dict[str, Any]] = qs_result.get("cursor", {}).get("firstBatch", []) + count = sum(1 for s in batch if s.get("queryShapeHash") == expected_hash) + assertSuccessPartial( + {"count": count}, + {"count": 0}, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Idempotent Removal]: calling removeQuerySettings a second time +# for the same query shape succeeds silently without error. +REMOVEQUERYSETTINGS_IDEMPOTENT_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "idempotent_removal", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + } + }, + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should succeed silently on second removal", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_IDEMPOTENT_TESTS)) +def test_removeQuerySettings_idempotent(collection, test): + """Test removeQuerySettings is idempotent on second call.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Shape Matching Includes Collection]: collection name is part of +# the query shape. Removing with a different collection does not affect the +# original setting. +# Property [Shape Matching Includes $db]: $db is part of the query shape. +# Removing with a different $db does not affect the original setting. +# Property [Shape Matching Includes Sort Direction]: sort direction is part +# of the query shape. Removing with a different sort direction does not +# affect the original setting. +# Property [Shape Matching Includes Extra Fields]: adding extra fields +# changes the query shape. Removing with extra fields does not affect the +# original filter-only setting. +REMOVEQUERYSETTINGS_SHAPE_PERSISTS_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "shape_collection_name_matters", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": "other_collection", + "filter": {"sm2": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm2": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with different collection should not affect original setting", + ), + SettingsTestCase( + "shape_db_matters", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm3": 1}, + "$db": "other_database", + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm3": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with different $db should not affect original setting", + ), + SettingsTestCase( + "shape_sort_direction_matters", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm4": 1}, + "sort": {"sm4": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm4": 1}, + "sort": {"sm4": -1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm4": 1}, + "sort": {"sm4": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with different sort direction should not affect original setting", + ), + SettingsTestCase( + "shape_extra_fields_change_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm5": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm5": 1}, + "sort": {"sm5": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm5": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with extra fields should not affect filter-only setting", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_SHAPE_PERSISTS_TESTS)) +def test_removeQuerySettings_shape_persists(collection, test): + """Test that mismatched shapes do not remove original settings.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + expected_hash = ctx.setup_results[0]["queryShapeHash"] + + execute_admin_command(collection, test.build_command(ctx)) + + admin = collection.database.client.admin + qs_result = admin.command( + {"aggregate": 1, "pipeline": [{"$querySettings": {}}], "cursor": {}} + ) + batch: list[dict[str, Any]] = qs_result.get("cursor", {}).get("firstBatch", []) + count = sum(1 for s in batch if s.get("queryShapeHash") == expected_hash) + assertSuccessPartial({"count": count}, {"count": 1}, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Reject Removal Restores Query]: removing a reject: true setting +# allows the previously-rejected query to succeed again. +REMOVEQUERYSETTINGS_REJECT_REMOVAL_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "reject_removal_restores_query", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rj1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rj1": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rj1": 1}, + "$db": ctx.database, + } + } + ], + msg="query should succeed after removing reject: true setting", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_REJECT_REMOVAL_TESTS)) +def test_removeQuerySettings_reject_removal(collection, test): + """Test that removing reject: true setting restores the query.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + + # Remove the reject setting + execute_admin_command(collection, test.build_command(ctx)) + + # Verify query succeeds after removal + restored = execute_command(collection, {"find": ctx.collection, "filter": {"rj1": 1}}) + assertSuccessPartial(restored, {"ok": 1.0}, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py new file mode 100644 index 000000000..0eff29d65 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py @@ -0,0 +1,311 @@ +"""Tests for removeQuerySettings command core acceptance behavior. + +Validates that the removeQuerySettings command accepts valid query shapes +for find, distinct, and aggregate commands, various shape variations, +$db field variations, hash-based removal, and idempotent behavior. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Find Shape Acceptance]: removeQuerySettings accepts find shapes +# with various field combinations without error. +REMOVEQUERYSETTINGS_FIND_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_find_filter_only", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with filter only", + ), + CommandTestCase( + "accepts_find_filter_sort", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b": 1}, + "sort": {"b": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with filter and sort", + ), + CommandTestCase( + "accepts_find_filter_projection", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"c": 1}, + "projection": {"c": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with filter and projection", + ), + CommandTestCase( + "accepts_find_filter_sort_projection", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"d": 1}, + "sort": {"d": 1}, + "projection": {"d": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with all shape fields", + ), + CommandTestCase( + "accepts_find_with_collation", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"e": "abc"}, + "collation": {"locale": "en", "strength": 2}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with collation", + ), + CommandTestCase( + "accepts_find_with_let", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"$expr": {"$eq": ["$f", "$$target"]}}, + "let": {"target": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with let", + ), + CommandTestCase( + "accepts_find_without_filter", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find without filter", + ), +] + +# Property [Distinct Shape Acceptance]: removeQuerySettings accepts distinct +# shapes with various field combinations without error. +REMOVEQUERYSETTINGS_DISTINCT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_distinct_key_only", + command=lambda ctx: { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "j", + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept distinct with key only", + ), + CommandTestCase( + "accepts_distinct_key_with_query", + command=lambda ctx: { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "k", + "query": {"$and": [{"k": {"$gt": 0}}, {"k": {"$lt": 100}}]}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept distinct with query filter", + ), +] + +# Property [Aggregate Shape Acceptance]: removeQuerySettings accepts aggregate +# pipeline shapes with various stage combinations without error. +REMOVEQUERYSETTINGS_AGGREGATE_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_aggregate_single_stage", + command=lambda ctx: { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"l": 1}}], + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept aggregate with single stage", + ), + CommandTestCase( + "accepts_aggregate_multi_stage", + command=lambda ctx: { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [ + {"$match": {"m": 1}}, + {"$group": {"_id": "$m", "count": {"$sum": 1}}}, + ], + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept aggregate with multiple stages", + ), +] + +# Property [Nonexistent $db Acceptance]: removeQuerySettings accepts +# non-existent database names in the $db field without error. +REMOVEQUERYSETTINGS_DB_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_nonexistent_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"o": 1}, + "$db": f"{ctx.database}_nonexistent", + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept non-existent $db", + ), +] + +# Property [Silent No-Op]: removeQuerySettings succeeds silently when +# no matching settings exist. +REMOVEQUERYSETTINGS_NOOP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "noop_nonexistent_query_shape", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"nonexistent_field": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should succeed when no matching settings exist", + ), + CommandTestCase( + "noop_nonexistent_hash", + command=lambda ctx: { + "removeQuerySettings": "00000000000000000000000000000000" + "00000000000000000000000000000000" + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should succeed with a non-existent hash", + ), + CommandTestCase( + "noop_lowercase_hash", + command=lambda ctx: { + "removeQuerySettings": "abcdef0123456789abcdef0123456789" + "abcdef0123456789abcdef0123456789" + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept lowercase hex hash", + ), +] + +# Property [IDHACK Query Acceptance]: unlike setQuerySettings which rejects +# IDHACK-eligible queries, removeQuerySettings accepts them. +REMOVEQUERYSETTINGS_IDHACK_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_idhack_query", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"_id": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept IDHACK-eligible queries", + ), +] + +# Property [Internal Database Acceptance]: unlike setQuerySettings which +# rejects internal databases, removeQuerySettings accepts them. +REMOVEQUERYSETTINGS_INTERNAL_DB_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_admin_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": "system.users", + "filter": {}, + "$db": "admin", + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept admin database query shapes", + ), + CommandTestCase( + "accepts_local_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": "oplog.rs", + "filter": {}, + "$db": "local", + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept local database query shapes", + ), +] + +# Property [Comment Field Acceptance]: removeQuerySettings accepts the +# comment top-level field without error. +REMOVEQUERYSETTINGS_COMMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_comment_field", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"cmt1": 1}, + "$db": ctx.database, + }, + "comment": "test comment", + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept comment field", + ), +] + +REMOVEQUERYSETTINGS_CORE_TESTS: list[CommandTestCase] = ( + REMOVEQUERYSETTINGS_FIND_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_DISTINCT_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_AGGREGATE_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_DB_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_NOOP_TESTS + + REMOVEQUERYSETTINGS_IDHACK_TESTS + + REMOVEQUERYSETTINGS_INTERNAL_DB_TESTS + + REMOVEQUERYSETTINGS_COMMENT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_CORE_TESTS)) +def test_removeQuerySettings_core(collection, test): + """Test removeQuerySettings command core acceptance behavior.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py new file mode 100644 index 000000000..1a942cec6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py @@ -0,0 +1,212 @@ +"""Tests for removeQuerySettings command error cases. + +Validates that the removeQuerySettings command rejects invalid BSON types for +the primary argument, malformed query shapes, invalid hash strings, and +unrecognized top-level fields. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_LENGTH_ERROR, + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.requires(cluster_admin=True), pytest.mark.no_parallel] + +# Property [Primary Argument Type Rejection]: the removeQuerySettings field +# must be a document or string. All other BSON types are rejected. +REMOVEQUERYSETTINGS_PRIMARY_ARG_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"primary_arg_{tid}", + command=lambda ctx, v=value: {"removeQuerySettings": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"removeQuerySettings should reject {tid} as the primary argument", + ) + for tid, value in [ + ("null", None), + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2, 3]), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(0, 0)), + ("binary", Binary(b"\x00")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Query Shape Validation]: rejects malformed query shape documents +# including empty documents, missing/empty/null $db, and unknown command types. +REMOVEQUERYSETTINGS_QUERY_SHAPE_VALIDATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_shape_empty_document", + command=lambda ctx: {"removeQuerySettings": {}}, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="removeQuerySettings should reject empty query shape document", + ), + CommandTestCase( + "query_shape_missing_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + } + }, + error_code=MISSING_FIELD_ERROR, + msg="removeQuerySettings should reject query shape missing $db field", + ), + CommandTestCase( + "query_shape_empty_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": "", + } + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="removeQuerySettings should reject query shape with empty $db", + ), + CommandTestCase( + "query_shape_null_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": None, + } + }, + error_code=MISSING_FIELD_ERROR, + msg="removeQuerySettings should reject query shape with null $db", + ), + CommandTestCase( + "query_shape_unknown_command", + command=lambda ctx: { + "removeQuerySettings": { + "unknownCommand": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + } + }, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="removeQuerySettings should reject unknown command type in query shape", + ), + CommandTestCase( + "query_shape_no_command_type", + command=lambda ctx: { + "removeQuerySettings": { + "filter": {"x": 1}, + "$db": ctx.database, + } + }, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="removeQuerySettings should reject query shape without a command type", + ), +] + +# Property [Hash String Validation]: rejects invalid hash string formats +# including empty, too short, too long, and non-hexadecimal strings. +REMOVEQUERYSETTINGS_HASH_VALIDATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_hash_string", + command=lambda ctx: {"removeQuerySettings": ""}, + error_code=INVALID_LENGTH_ERROR, + msg="removeQuerySettings should reject empty hash string", + ), + CommandTestCase( + "short_hash_string", + command=lambda ctx: {"removeQuerySettings": "ABCD"}, + error_code=INVALID_LENGTH_ERROR, + msg="removeQuerySettings should reject short hash string", + ), + CommandTestCase( + "long_hash_string", + command=lambda ctx: {"removeQuerySettings": "AA" * 33}, + error_code=INVALID_LENGTH_ERROR, + msg="removeQuerySettings should reject hash string longer than 64 chars", + ), + CommandTestCase( + "non_hex_hash_string", + command=lambda ctx: { + "removeQuerySettings": "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG" + "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG" + }, + error_code=BAD_VALUE_ERROR, + msg="removeQuerySettings should reject non-hex hash string", + ), +] + +# Property [Unrecognized Fields]: rejects unknown top-level command fields +# and fields valid for setQuerySettings but not removeQuerySettings. +REMOVEQUERYSETTINGS_UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unrecognized_top_level_field", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="removeQuerySettings should reject unrecognized top-level field", + ), + CommandTestCase( + "settings_field_rejected", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="removeQuerySettings should reject settings field", + ), +] + +REMOVEQUERYSETTINGS_ERROR_TESTS: list[CommandTestCase] = ( + REMOVEQUERYSETTINGS_PRIMARY_ARG_TYPE_TESTS + + REMOVEQUERYSETTINGS_QUERY_SHAPE_VALIDATION_TESTS + + REMOVEQUERYSETTINGS_HASH_VALIDATION_TESTS + + REMOVEQUERYSETTINGS_UNRECOGNIZED_FIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_ERROR_TESTS)) +def test_removeQuerySettings_error(collection, test): + """Test removeQuerySettings error cases.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + ) From 2e606eaacf881659d26e3f4afd0821a5368eb1ca Mon Sep 17 00:00:00 2001 From: Ian Forster Date: Thu, 2 Jul 2026 11:52:33 -0700 Subject: [PATCH 20/51] Feature: query-and-write readConcern parameter (#615) Signed-off-by: Ian Forster Signed-off-by: Victor Tsang Co-authored-by: Victor Tsang --- .../test_read_concern_bson_type_validation.py | 82 +++++++++++ .../test_read_concern_command_interaction.py | 134 ++++++++++++++++++ .../read_concern/test_read_concern_errors.py | 134 ++++++++++++++++++ .../test_read_concern_level_acceptance.py | 94 ++++++++++++ .../read_concern/test_read_concern_local.py | 77 ++++++++++ .../test_read_concern_write_commands.py | 70 +++++++++ .../read_concern/test_smoke_read_concern.py | 6 +- 7 files changed, 592 insertions(+), 5 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_command_interaction.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_level_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_write_commands.py diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_bson_type_validation.py new file mode 100644 index 000000000..32a2f97f7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_bson_type_validation.py @@ -0,0 +1,82 @@ +"""readConcern BSON type validation: field must be a document, level must be a string.""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command + +READ_CONCERN_PARAMS = [ + BsonTypeTestCase( + id="read_concern_field", + msg="readConcern field should reject non-document BSON types", + valid_types=[BsonType.OBJECT], + skip_rejection_types=[BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + expected=[{"_id": 1, "x": 1}], + valid_inputs={BsonType.OBJECT: {"level": "local"}}, + ), + BsonTypeTestCase( + id="level_field", + msg="readConcern.level should reject non-string BSON types", + valid_types=[BsonType.STRING], + skip_rejection_types=[BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + expected=[{"_id": 1, "x": 1}], + valid_inputs={BsonType.STRING: "local"}, + ), +] + + +def _build_read_concern(spec, sample_value): + """Build the readConcern value based on which aspect is being tested.""" + if spec.id == "read_concern_field": + return sample_value + # level_field: wrap the sample as the level sub-field. + return {"level": sample_value} + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(READ_CONCERN_PARAMS) +) +def test_read_concern_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test readConcern rejects invalid BSON types for the field and level sub-field.""" + collection.insert_one({"_id": 1, "x": 1}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": _build_read_concern(spec, sample_value), + }, + ) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"readConcern should reject {bson_type.value} for {spec.id}", + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(READ_CONCERN_PARAMS) +) +def test_read_concern_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test readConcern accepts valid BSON types for the field and level sub-field.""" + collection.insert_one({"_id": 1, "x": 1}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": _build_read_concern(spec, sample_value), + }, + ) + assertSuccess( + result, spec.expected, msg=f"readConcern should accept {bson_type.value} for {spec.id}" + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_command_interaction.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_command_interaction.py new file mode 100644 index 000000000..abeb42381 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_command_interaction.py @@ -0,0 +1,134 @@ +"""readConcern with find: command options, empty/non-existent collections, views, and getMore.""" + +from typing import Any, Dict, cast + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertProperties, assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ViewCollection + +INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_with_options_and_read_concern", + docs=[{"_id": 1, "x": 3, "y": 9}, {"_id": 2, "x": 1, "y": 9}, {"_id": 3, "x": 2, "y": 9}], + command={ + "filter": {}, + "sort": {"x": 1}, + "projection": {"x": 1, "_id": 1}, + "limit": 2, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + msg="find with readConcern should not interfere with sort/projection/limit options.", + ), + CommandTestCase( + "find_on_empty_collection", + docs=[], + command={"filter": {}, "readConcern": {"level": "local"}}, + expected=[], + msg="find with readConcern on empty collection should return empty.", + ), + CommandTestCase( + "find_on_nonexistent_collection", + docs=None, + command={"filter": {}, "readConcern": {"level": "local"}}, + expected=[], + msg="find with readConcern on non-existent collection should return empty.", + ), + CommandTestCase( + "find_on_view", + target_collection=ViewCollection(options={"pipeline": [{"$match": {"x": {"$gte": 10}}}]}), + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 5}], + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + msg="find with readConcern on view should return filtered view results.", + ), + CommandTestCase( + "find_first_batch_with_batch_size", + docs=[{"_id": i, "x": i} for i in range(10)], + command={ + "filter": {}, + "batchSize": 3, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 0, "x": 0}, {"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="first batch from find with readConcern should contain 3 documents.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INTERACTION_TESTS)) +def test_read_concern_command_interaction(collection, test: CommandTestCase): + """Test readConcern works with other command options, collection states, and views.""" + collection = test.prepare(collection.database, collection) + find_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"find": collection.name, **find_body}) + assertResult(result, expected=test.expected, msg=test.msg) + + +def test_getmore_after_find_with_read_concern_next_batch(collection): + """Test getMore after find with readConcern returns next batch correctly.""" + collection.insert_many([{"_id": i, "x": i} for i in range(10)]) + + initial_result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "batchSize": 3, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + ) + cursor_id = initial_result["cursor"]["id"] + + getmore_result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 3}, + ) + expected_next = [{"_id": 3, "x": 3}, {"_id": 4, "x": 4}, {"_id": 5, "x": 5}] + assertProperties( + getmore_result, + {"cursor.nextBatch": Eq(expected_next), "ok": Eq(1.0)}, + raw_res=True, + msg="getMore after readConcern find should return next batch correctly.", + ) + + +def test_getmore_ignores_read_concern_parameter(collection): + """Test getMore ignores a readConcern parameter and still returns the next batch.""" + collection.insert_many([{"_id": i, "x": i} for i in range(10)]) + + initial_result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "batchSize": 3, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + ) + cursor_id = initial_result["cursor"]["id"] + + getmore_result = execute_command( + collection, + { + "getMore": cursor_id, + "collection": collection.name, + "batchSize": 3, + "readConcern": {"level": "local"}, + }, + ) + expected_next = [{"_id": 3, "x": 3}, {"_id": 4, "x": 4}, {"_id": 5, "x": 5}] + assertProperties( + getmore_result, + {"cursor.nextBatch": Eq(expected_next), "ok": Eq(1.0)}, + raw_res=True, + msg="getMore should ignore a readConcern parameter and return the next batch.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_errors.py new file mode 100644 index 000000000..1191e7399 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_errors.py @@ -0,0 +1,134 @@ +"""readConcern rejection cases with find: bad levels, unknown fields, topology, afterClusterTime.""" + +from typing import Any, Dict, cast + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + NOT_A_REPLICA_SET_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +_REQUIRES_STANDALONE = (pytest.mark.requires(cluster_read_concern=False),) +_REQUIRES_REPLICA_SET = (pytest.mark.requires(cluster_read_concern=True),) + +INVALID_LEVEL_STRING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_rejects_empty_string_level", + command={"filter": {}, "readConcern": {"level": ""}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject empty readConcern level string.", + ), + CommandTestCase( + "find_rejects_unknown_level_string", + command={"filter": {}, "readConcern": {"level": "invalid"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject unrecognized readConcern level string 'invalid'.", + ), + CommandTestCase( + "find_rejects_uppercase_level", + command={"filter": {}, "readConcern": {"level": "LOCAL"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject uppercase readConcern level 'LOCAL'.", + ), + CommandTestCase( + "find_rejects_mixed_case_level", + command={"filter": {}, "readConcern": {"level": "Majority"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject mixed-case readConcern level 'Majority'.", + ), + CommandTestCase( + "find_rejects_nonexistent_level", + command={"filter": {}, "readConcern": {"level": "strong"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject nonexistent readConcern level 'strong'.", + ), + CommandTestCase( + "find_rejects_null_byte_in_level", + command={"filter": {}, "readConcern": {"level": "local\x00extra"}}, + error_code=BAD_VALUE_ERROR, + msg="find should reject null byte in readConcern level string.", + ), +] + +UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_rejects_unknown_field_no_level", + command={"filter": {}, "readConcern": {"unknownField": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="find should reject readConcern with unknown field and no level.", + ), + CommandTestCase( + "find_rejects_extra_field_with_valid_level", + command={"filter": {}, "readConcern": {"level": "local", "unknownField": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="find should reject readConcern with extra unknown field.", + ), +] + + +AFTER_CLUSTER_TIME_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_rejects_afterClusterTime_string", + command={"filter": {}, "readConcern": {"level": "local", "afterClusterTime": "invalid"}}, + error_code=TYPE_MISMATCH_ERROR, + msg="find should reject non-Timestamp afterClusterTime (string).", + marks=_REQUIRES_REPLICA_SET, + ), + CommandTestCase( + "find_rejects_afterClusterTime_integer", + command={"filter": {}, "readConcern": {"level": "local", "afterClusterTime": 12345}}, + error_code=TYPE_MISMATCH_ERROR, + msg="find should reject non-Timestamp afterClusterTime (integer).", + marks=_REQUIRES_REPLICA_SET, + ), + CommandTestCase( + "find_rejects_afterClusterTime_null", + command={"filter": {}, "readConcern": {"level": "local", "afterClusterTime": None}}, + error_code=TYPE_MISMATCH_ERROR, + msg="find should reject non-Timestamp afterClusterTime (null).", + marks=_REQUIRES_REPLICA_SET, + ), +] + +# 'snapshot' and 'linearizable' both require a replicated topology and are rejected on standalone. +REPLICA_SET_ONLY_LEVEL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_rejects_snapshot_on_standalone", + docs=[{"_id": 1}], + command={"filter": {}, "readConcern": {"level": "snapshot"}}, + error_code=NOT_A_REPLICA_SET_ERROR, + msg="readConcern 'snapshot' should be rejected on a standalone (not a replica set).", + marks=_REQUIRES_STANDALONE, + ), + CommandTestCase( + "find_rejects_linearizable_on_standalone", + docs=[{"_id": 1}], + command={"filter": {}, "readConcern": {"level": "linearizable"}}, + error_code=NOT_A_REPLICA_SET_ERROR, + msg="readConcern 'linearizable' should be rejected on a standalone (not a replica set).", + marks=_REQUIRES_STANDALONE, + ), +] + +ERROR_TESTS: list[CommandTestCase] = ( + INVALID_LEVEL_STRING_TESTS + + UNKNOWN_FIELD_TESTS + + REPLICA_SET_ONLY_LEVEL_TESTS + + AFTER_CLUSTER_TIME_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_read_concern_rejected(collection, test: CommandTestCase): + """Test readConcern rejection cases return the expected error code.""" + collection = test.prepare(collection.database, collection) + find_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"find": collection.name, **find_body}) + assertResult(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_level_acceptance.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_level_acceptance.py new file mode 100644 index 000000000..731c46d16 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_level_acceptance.py @@ -0,0 +1,94 @@ +"""readConcern acceptance with find: valid levels, default shapes, and valid afterClusterTime.""" + +from typing import Any, Dict, cast + +import pytest +from bson import Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +_TWO_DOCS = [{"_id": 1, "x": 1}, {"_id": 2, "x": 2}] + +ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_accepts_local", + docs=_TWO_DOCS, + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=_TWO_DOCS, + msg="find should accept readConcern level 'local'.", + ), + CommandTestCase( + "find_accepts_available", + docs=_TWO_DOCS, + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "available"}}, + expected=_TWO_DOCS, + msg="find should accept readConcern level 'available'.", + ), + CommandTestCase( + "find_accepts_majority", + docs=_TWO_DOCS, + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "majority"}}, + expected=_TWO_DOCS, + msg="find should accept readConcern level 'majority'.", + ), + CommandTestCase( + "find_accepts_linearizable", + docs=[{"_id": 1, "x": 1}], + command={"filter": {"_id": 1}, "readConcern": {"level": "linearizable"}}, + expected=[{"_id": 1, "x": 1}], + msg="find should accept readConcern level 'linearizable'.", + marks=(pytest.mark.requires(cluster_read_concern=True),), + ), + CommandTestCase( + "find_accepts_snapshot", + docs=[{"_id": 1, "x": 1}], + command={"filter": {"_id": 1}, "readConcern": {"level": "snapshot"}}, + expected=[{"_id": 1, "x": 1}], + msg="find should accept readConcern level 'snapshot' on a replica set.", + marks=(pytest.mark.requires(cluster_read_concern=True),), + ), + CommandTestCase( + "find_accepts_empty_document", + docs=[{"_id": 1, "x": 1}], + command={"filter": {}, "readConcern": {}}, + expected=[{"_id": 1, "x": 1}], + msg="find should accept empty readConcern document.", + ), + CommandTestCase( + "find_accepts_null_read_concern", + docs=[{"_id": 1, "x": 1}], + command={"filter": {}, "readConcern": None}, + expected=[{"_id": 1, "x": 1}], + msg="find should treat null readConcern as omitted.", + ), + CommandTestCase( + "find_accepts_null_level", + docs=[{"_id": 1, "x": 1}], + command={"filter": {}, "readConcern": {"level": None}}, + expected=[{"_id": 1, "x": 1}], + msg="find should treat readConcern {level: null} as implicit default.", + ), + CommandTestCase( + "find_accepts_valid_after_cluster_time", + docs=[{"_id": 1, "x": 1}], + command={ + "filter": {"_id": 1}, + "readConcern": {"level": "local", "afterClusterTime": Timestamp(1, 1)}, + }, + expected=[{"_id": 1, "x": 1}], + msg="find should accept a valid Timestamp afterClusterTime on a replica set.", + marks=(pytest.mark.requires(cluster_read_concern=True),), + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ACCEPTANCE_TESTS)) +def test_read_concern_level_acceptance(collection, test: CommandTestCase): + """Test readConcern level (and default-equivalent shapes) is accepted by find.""" + collection = test.prepare(collection.database, collection) + find_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"find": collection.name, **find_body}) + assertResult(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local.py new file mode 100644 index 000000000..1dbc3e7d3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local.py @@ -0,0 +1,77 @@ +"""readConcern level 'local' availability and behavior with find.""" + +from typing import Any, Dict, cast + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + + +def _update_status(coll): + """Update _id 1's status to 'done' via a write command.""" + execute_command( + coll, + {"update": coll.name, "updates": [{"q": {"_id": 1}, "u": {"$set": {"status": "done"}}}]}, + ) + + +def _delete_first(coll): + """Delete _id 1 via a write command.""" + execute_command(coll, {"delete": coll.name, "deletes": [{"q": {"_id": 1}, "limit": 1}]}) + + +# Each ``command`` is the find body (everything except the collection name); the +# test runner prepends ``"find": ``. +LOCAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_omitted_read_concern_is_default", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={"filter": {}, "sort": {"_id": 1}}, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="find without readConcern should return all documents (implicit default).", + ), + CommandTestCase( + "find_local_without_session", + docs=[{"_id": 1, "v": "a"}, {"_id": 2, "v": "b"}], + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=[{"_id": 1, "v": "a"}, {"_id": 2, "v": "b"}], + msg="find with readConcern 'local' must be available without a session or transaction.", + ), + CommandTestCase( + "find_reads_fresh_data", + docs=[{"_id": 1, "score": 100}], + command={"filter": {"score": 100}, "readConcern": {"level": "local"}}, + expected=[{"_id": 1, "score": 100}], + msg="find with readConcern 'local' should return inserted documents.", + ), + CommandTestCase( + "find_sees_updated_document", + docs=[{"_id": 1, "status": "pending"}], + setup=_update_status, + command={"filter": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=[{"_id": 1, "status": "done"}], + msg="find with readConcern 'local' must reflect an update applied to the local instance.", + ), + CommandTestCase( + "find_reflects_delete", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + setup=_delete_first, + command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "local"}}, + expected=[{"_id": 2}, {"_id": 3}], + msg="find with readConcern 'local' must reflect a deletion applied to the local instance.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LOCAL_TESTS)) +def test_read_concern_local(collection, test: CommandTestCase): + """Test readConcern level 'local' availability and behavior.""" + collection = test.prepare(collection.database, collection) + if test.setup: + test.setup(collection) + find_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"find": collection.name, **find_body}) + assertResult(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_write_commands.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_write_commands.py new file mode 100644 index 000000000..8a4ffa589 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_write_commands.py @@ -0,0 +1,70 @@ +"""readConcern on write commands (insert, update, delete, findAndModify) outside transactions.""" + +from typing import Any, Dict, cast + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +WRITE_COMMAND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "insert_with_read_concern", + command=lambda ctx: { + "insert": ctx.collection, + "documents": [{"_id": 1, "x": 1}], + "readConcern": {"level": "local"}, + }, + expected={"n": 1, "ok": 1.0}, + msg="insert should accept readConcern outside transaction.", + ), + CommandTestCase( + "update_with_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"x": 2}}}], + "readConcern": {"level": "local"}, + }, + expected={"n": 1, "nModified": 1, "ok": 1.0}, + msg="update should accept readConcern outside transaction.", + ), + CommandTestCase( + "delete_with_read_concern", + docs=[{"_id": 1}], + command=lambda ctx: { + "delete": ctx.collection, + "deletes": [{"q": {"_id": 1}, "limit": 1}], + "readConcern": {"level": "local"}, + }, + expected={"n": 1, "ok": 1.0}, + msg="delete should accept readConcern outside transaction.", + ), + CommandTestCase( + "find_and_modify_with_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "findAndModify": ctx.collection, + "query": {"_id": 1}, + "update": {"$set": {"x": 2}}, + "readConcern": {"level": "local"}, + }, + expected={"value": {"_id": 1, "x": 1}, "ok": 1.0}, + msg="findAndModify should accept readConcern outside transaction.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_COMMAND_TESTS)) +def test_write_command_with_read_concern(collection, test: CommandTestCase): + """Test write commands accept readConcern outside transaction.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + expected = cast(Dict[str, Any], test.build_expected(ctx)) + assertSuccessPartial(result, expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_smoke_read_concern.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_smoke_read_concern.py index 140e0a288..dc0f2eae2 100644 --- a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_smoke_read_concern.py +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_smoke_read_concern.py @@ -1,8 +1,4 @@ -""" -Smoke test for readConcern. - -Tests basic readConcern functionality. -""" +"""Smoke test for basic readConcern functionality.""" import pytest From 0aa29ffa1a7a3726d95d4f55e1f24bc860485bb6 Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Thu, 2 Jul 2026 12:23:12 -0700 Subject: [PATCH 21/51] Add query and write tests for findAndModify (#619) Signed-off-by: Victor Tsang --- .../test_findAndModify_argument_validation.py | 190 +++++++ ...test_findAndModify_bson_type_validation.py | 240 ++++++++ .../test_findAndModify_core_behavior.py | 491 +++++++++++++++++ .../test_findAndModify_data_types.py | 188 +++++++ ...st_findAndModify_dollar_prefixed_fields.py | 77 +++ .../test_findAndModify_errors.py | 514 ++++++++++++++++++ .../test_findAndModify_expr_let.py | 116 ++++ .../test_findAndModify_projection.py | 170 ++++++ .../test_findAndModify_update_modes.py | 289 ++++++++++ .../test_findAndModify_upsert.py | 461 ++++++++++++++++ .../test_findAndModify_with_expr.py | 46 -- 11 files changed, 2736 insertions(+), 46 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_projection.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py delete mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py new file mode 100644 index 000000000..53b7b02d6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py @@ -0,0 +1,190 @@ +"""Tests for findAndModify argument handling — success cases for valid parameter variants.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +ALL_TESTS = [ + CommandTestCase( + "hint-string-existing-index", + docs=[{"_id": 1, "x": 10}], + indexes=[IndexModel("x", name="x_1")], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": "x_1", + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with hint as index-name string succeeds", + ), + CommandTestCase( + "hint-spec-document", + docs=[{"_id": 1, "x": 10}], + indexes=[IndexModel("x")], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": {"x": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with hint as index-specification document succeeds", + ), + CommandTestCase( + "empty-query-selects-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {}, + "update": {"$set": {"x": 20}}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="findAndModify with empty query {} selects a document", + ), + CommandTestCase( + "write-concern-w1", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "writeConcern": {"w": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with writeConcern w:1 succeeds", + ), + CommandTestCase( + "comment-string", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "comment": "test comment", + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify with comment as string succeeds", + ), + CommandTestCase( + "maxTimeMS-zero", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "maxTimeMS": 0, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify with maxTimeMS:0 succeeds (unbounded)", + ), + CommandTestCase( + "sort-empty-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "sort": {}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="findAndModify with sort:{} is accepted (no sort applied)", + ), + CommandTestCase( + "fields-empty-document-returns-full-doc", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {}, + }, + expected={"value": Eq({"_id": 1, "x": 10, "y": 20})}, + msg="findAndModify with fields:{} returns the full document", + ), + CommandTestCase( + "hint-empty-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": {}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with hint:{} empty document succeeds", + ), + CommandTestCase( + "bypass-validation-as-int", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "bypassDocumentValidation": 1, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify accepts bypassDocumentValidation as integer", + ), + CommandTestCase( + "bypass-validation-as-null", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "bypassDocumentValidation": None, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify accepts bypassDocumentValidation as null", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_argument_validation(database_client, collection, test): + """Test findAndModify argument handling - success cases for valid parameter variants.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_bypass_validation_allows_invalid_write(database_client, request): + """Test bypassDocumentValidation:true allows write that violates validator.""" + db = database_client + coll_name = f"{request.node.name}_validated" + db.create_collection(coll_name, validator={"$jsonSchema": {"required": ["name"]}}) + coll = db[coll_name] + coll.insert_one({"_id": 1, "name": "test"}) + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "update": {"$unset": {"name": ""}}, + "bypassDocumentValidation": True, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py new file mode 100644 index 000000000..7200ddff3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py @@ -0,0 +1,240 @@ +""" +BSON type validation tests for findAndModify command parameters. + +Verifies that findAndModify correctly rejects invalid BSON types for all +parameters and accepts valid types. +""" + +import pytest +from bson import Int64 +from bson.decimal128 import Decimal128 + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + INVALID_NAMESPACE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command + +BSON_PARAMS = [ + BsonTypeTestCase( + id="findAndModify", + msg="findAndModify collection name should reject non-string types", + keyword="findAndModify", + valid_types=[BsonType.STRING], + default_error_code=INVALID_NAMESPACE_ERROR, + ), + BsonTypeTestCase( + id="query", + msg="findAndModify query should reject non-document types", + keyword="query", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="sort", + msg="findAndModify sort should reject non-document types", + keyword="sort", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="update", + msg="findAndModify update should reject non-document/array types", + keyword="update", + valid_types=[BsonType.OBJECT, BsonType.ARRAY], + valid_inputs={ + BsonType.OBJECT: {"$set": {"x": 1}}, + BsonType.ARRAY: [{"$set": {"x": 1}}], + }, + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="fields", + msg="findAndModify fields should reject non-document types", + keyword="fields", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"x": 1}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="remove", + msg="findAndModify remove should reject non-numeric/non-bool types", + keyword="remove", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="new", + msg="findAndModify new should reject non-numeric/non-bool types", + keyword="new", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="upsert", + msg="findAndModify upsert should reject non-numeric/non-bool types", + keyword="upsert", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="bypassDocumentValidation", + msg="findAndModify bypassDocumentValidation should reject non-numeric/non-bool types", + keyword="bypassDocumentValidation", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="maxTimeMS", + msg="findAndModify maxTimeMS should reject non-numeric types", + keyword="maxTimeMS", + valid_types=[ + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + valid_inputs={ + BsonType.INT: 100, + BsonType.LONG: Int64(100), + BsonType.DOUBLE: 100.0, + BsonType.DECIMAL: Decimal128("100"), + BsonType.NULL: None, + }, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="collation", + msg="findAndModify collation should reject non-document types", + keyword="collation", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"locale": "en"}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="writeConcern", + msg="findAndModify writeConcern should reject non-document types", + keyword="writeConcern", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"w": 1}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="let", + msg="findAndModify let should reject non-document types", + keyword="let", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"a": 1}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="arrayFilters", + msg="findAndModify arrayFilters should reject non-array types", + keyword="arrayFilters", + valid_types=[BsonType.ARRAY, BsonType.NULL], + valid_inputs={BsonType.ARRAY: [], BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="hint", + msg="findAndModify hint should reject non-document/non-string types", + keyword="hint", + valid_types=[BsonType.OBJECT, BsonType.STRING], + valid_inputs={ + BsonType.OBJECT: {"_id": 1}, + BsonType.STRING: "_id_", + }, + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="comment", + msg="findAndModify comment should accept all BSON types", + keyword="comment", + valid_types=list(BsonType), + ), +] + +REJECTION_TESTS = generate_bson_rejection_test_cases(BSON_PARAMS) +ACCEPTANCE_TESTS = generate_bson_acceptance_test_cases(BSON_PARAMS) + + +def _build_command(collection_name, spec, sample_value): + """Build a findAndModify command with sample_value placed at spec.keyword.""" + cmd = { + "findAndModify": collection_name, + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + spec.keyword: sample_value, + } + if spec.id == "remove" and sample_value: + cmd.pop("update", None) + return cmd + + +def _build_expected(spec, sample_value): + """Build expected partial result based on which parameter is being tested.""" + # These keywords replace query or collection name, so the doc may not be found + if spec.keyword in ("query", "findAndModify"): + return {"ok": 1.0} + # remove with truthy value deletes the matched doc + if spec.id == "remove" and sample_value: + return {"ok": 1.0, "lastErrorObject": {"n": 1}, "value": {"_id": 1, "x": 10}} + # All other keywords: query is {_id:1} which matches, update runs + return {"ok": 1.0, "lastErrorObject": {"n": 1, "updatedExisting": True}} + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_TESTS) +def test_findAndModify_bson_type_rejected(collection, bson_type, sample_value, spec): + """Verifies findAndModify rejects invalid BSON types for each parameter.""" + collection.insert_one({"_id": 1, "x": 10}) + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_TESTS) +def test_findAndModify_bson_type_accepted(collection, bson_type, sample_value, spec): + """Verifies findAndModify accepts valid BSON types for each parameter.""" + collection.insert_one({"_id": 1, "x": 10}) + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertSuccessPartial( + result, + _build_expected(spec, sample_value), + msg=f"findAndModify should accept {bson_type.value} for {spec.keyword}", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py new file mode 100644 index 000000000..cf0818571 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py @@ -0,0 +1,491 @@ +""" +Tests for findAndModify core behavior: update, remove, upsert, new flag, +and sort selection. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +NEW_FLAG_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "update-new-false-returns-pre-image", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "new": False, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + "ok": Eq(1.0), + }, + msg="update with new:false returns pre-modification document", + ), + CommandTestCase( + "update-new-true-returns-post-image", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 20}), + "ok": Eq(1.0), + }, + msg="update with new:true returns post-modification document", + ), + CommandTestCase( + "new-omitted-defaults-to-pre-image", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="new omitted defaults to returning pre-modification document", + ), + CommandTestCase( + "new-true-noop-update-returns-doc", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="new:true on noop update still returns doc with updatedExisting", + ), +] + +REMOVE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "remove-returns-removed-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "remove": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1}), + "value": Eq({"_id": 1, "x": 10}), + "ok": Eq(1.0), + }, + msg="remove returns the removed document", + ), + CommandTestCase( + "remove-no-match-returns-null", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 999}, + "remove": True, + }, + expected={ + "lastErrorObject": Eq({"n": 0}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="remove with no match returns value:null", + ), + CommandTestCase( + "remove-empty-query-deletes-one", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + command={ + "query": {}, + "remove": True, + }, + expected={"lastErrorObject": Eq({"n": 1}), "ok": Eq(1.0)}, + msg="remove with empty query deletes one document", + ), + CommandTestCase( + "remove-descending-id-sort", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + command={ + "query": {}, + "remove": True, + "sort": {"_id": -1}, + }, + expected={"value": Eq({"_id": 3, "x": 30})}, + msg="remove with sort:{_id:-1} removes highest _id", + ), + CommandTestCase( + "remove-ascending-id-sort", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + command={ + "query": {}, + "remove": True, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="remove with sort:{_id:1} removes lowest _id", + ), + CommandTestCase( + "remove-nested-field-sort", + docs=[ + {"_id": 1, "a": {"b": 30}}, + {"_id": 2, "a": {"b": 10}}, + {"_id": 3, "a": {"b": 20}}, + ], + command={ + "query": {}, + "remove": True, + "sort": {"a.b": -1}, + }, + expected={"value": Eq({"_id": 1, "a": {"b": 30}})}, + msg="remove with sort on nested field path selects correct doc", + ), + CommandTestCase( + "remove-with-query-and-sort", + docs=[ + {"_id": 1, "x": 5}, + {"_id": 2, "x": 15}, + {"_id": 3, "x": 25}, + ], + command={ + "query": {"x": {"$gt": 10}}, + "remove": True, + "sort": {"x": -1}, + }, + expected={"value": Eq({"_id": 3, "x": 25})}, + msg="remove with range query and descending sort selects highest match", + ), + CommandTestCase( + "truthy-numeric-remove", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "remove": 1, + }, + expected={ + "value": Eq({"_id": 1, "x": 10}), + "lastErrorObject": Eq({"n": 1}), + }, + msg="accepts truthy numeric value for remove flag", + ), +] + +UPSERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upsert-inserts-new-document", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + "ok": Eq(1.0), + }, + msg="upsert inserts when no match; lastErrorObject has upserted", + ), +] + +SORT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "sort-ascending-selects-first", + docs=[{"_id": 1, "x": 30}, {"_id": 2, "x": 10}, {"_id": 3, "x": 20}], + command={ + "query": {}, + "update": {"$set": {"modified": True}}, + "sort": {"x": 1}, + }, + expected={"value": Eq({"_id": 2, "x": 10})}, + msg="sort:{x:1} selects lowest x value", + ), + CommandTestCase( + "sort-descending-selects-highest", + docs=[{"_id": 1, "x": 30}, {"_id": 2, "x": 10}, {"_id": 3, "x": 20}], + command={ + "query": {}, + "update": {"$set": {"modified": True}}, + "sort": {"x": -1}, + }, + expected={"value": Eq({"_id": 1, "x": 30})}, + msg="sort:{x:-1} selects highest x value", + ), + CommandTestCase( + "sort-with-update-returns-pre-image", + docs=[ + {"_id": 1, "priority": 1, "count": 0}, + {"_id": 2, "priority": 5, "count": 0}, + {"_id": 3, "priority": 3, "count": 0}, + ], + command={ + "query": {}, + "sort": {"priority": -1}, + "update": {"$inc": {"count": 1}, "$set": {"active": True}}, + }, + expected={"value": Eq({"_id": 2, "priority": 5, "count": 0})}, + msg="sort with multi-operator update returns pre-image by default", + ), + CommandTestCase( + "sort-descending-updates-highest", + docs=[ + {"_id": 1, "priority": 1}, + {"_id": 2, "priority": 2}, + {"_id": 3, "priority": 3}, + ], + command={ + "query": {}, + "sort": {"priority": -1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 3, "priority": 3})}, + msg="descending sort updates highest-ranked document", + ), + CommandTestCase( + "compound-sort", + docs=[ + {"_id": 1, "a": 1, "b": 2}, + {"_id": 2, "a": 1, "b": 1}, + {"_id": 3, "a": 2, "b": 1}, + ], + command={ + "query": {}, + "sort": {"a": 1, "b": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 2, "a": 1, "b": 1})}, + msg="sort with multiple fields (compound sort) selects correctly", + ), + CommandTestCase( + "sort-id-ascending-selects-min", + docs=[{"_id": 3, "x": 1}, {"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={ + "query": {}, + "sort": {"_id": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 1, "x": 1})}, + msg="sort:{_id:1} selects minimum _id", + ), + CommandTestCase( + "sort-id-descending-selects-max", + docs=[{"_id": 3, "x": 1}, {"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={ + "query": {}, + "sort": {"_id": -1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 3, "x": 1})}, + msg="sort:{_id:-1} selects maximum _id", + ), + CommandTestCase( + "sort-on-field-absent-in-some-docs", + docs=[ + {"_id": 1, "x": 10}, + {"_id": 2}, + {"_id": 3, "x": 5}, + {"_id": 4}, + ], + command={ + "query": {}, + "sort": {"x": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 2})}, + msg="sort on field absent in some docs: missing field sorts before present values", + ), + CommandTestCase( + "sort-descending-field-absent-selects-highest", + docs=[ + {"_id": 1, "x": 10}, + {"_id": 2}, + {"_id": 3, "x": 5}, + ], + command={ + "query": {}, + "sort": {"x": -1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="sort descending on field absent in some docs selects highest value", + ), + CommandTestCase( + "sort-mixed-bson-types-ascending", + docs=[ + {"_id": 1, "x": "hello"}, + {"_id": 2, "x": 10}, + {"_id": 3, "x": None}, + {"_id": 4, "x": True}, + ], + command={ + "query": {}, + "sort": {"x": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 3, "x": None})}, + msg="sort ascending on mixed BSON types follows BSON comparison order", + ), +] + +UPDATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "update-no-match-returns-null", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 999}, + "update": {"$set": {"x": 20}}, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="update with no match returns value:null", + ), + CommandTestCase( + "update-empty-collection-returns-null", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="update on empty collection returns null", + ), + CommandTestCase( + "update-operators-inc-push", + docs=[{"_id": 1, "x": 10, "tags": ["a"]}], + command={ + "query": {"_id": 1}, + "update": {"$inc": {"x": 5}, "$push": {"tags": "b"}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 15, "tags": ["a", "b"]})}, + msg="update operators ($inc/$push) apply correctly", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + NEW_FLAG_TESTS + REMOVE_TESTS + UPSERT_TESTS + SORT_TESTS + UPDATE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_core(database_client, collection, test): + """Test findAndModify core behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_remove_actually_deletes_document(collection): + """Test findAndModify remove deletes the document from the collection.""" + collection.insert_one({"_id": 1, "x": 10}) + execute_command( + collection, + {"findAndModify": collection.name, "query": {"_id": 1}, "remove": True}, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, []) + + +def test_findAndModify_upsert_new_false_still_inserts(collection): + """Test findAndModify upsert with new:false still inserts the document.""" + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": False, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "x": 10}]) + + +def test_findAndModify_updates_exactly_one_document(collection): + """Test findAndModify updates only one document when query matches multiple.""" + collection.insert_many([{"_id": i, "status": "active", "x": i} for i in range(1, 6)]) + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"status": "active"}, + "sort": {"_id": 1}, + "update": {"$set": {"status": "done"}}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"status": "done"}}) + assertSuccess(result, [{"_id": 1, "status": "done", "x": 1}]) + + +def test_findAndModify_empty_query_no_sort_single_mod(collection): + """Test findAndModify with empty query and no sort modifies exactly one document.""" + collection.insert_many([{"_id": i, "x": i} for i in range(1, 6)]) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {}, + "update": {"$set": {"done": True}}, + }, + ) + assertSuccessPartial(result, {"lastErrorObject": {"n": 1, "updatedExisting": True}}) + + +def test_findAndModify_remove_nonexistent_collection(database_client, request): + """Test findAndModify remove on non-existent collection returns null, ok:1.""" + coll = database_client[f"{request.node.name}_nonexistent"] + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "remove": True, + }, + ) + assertSuccess( + result, + {"lastErrorObject": {"n": 0}, "value": None, "ok": 1.0}, + raw_res=True, + ) + + +def test_findAndModify_upsert_creates_collection(database_client, request): + """Test findAndModify upsert on non-existent collection creates it.""" + coll = database_client[f"{request.node.name}_new"] + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "upsert": True, + "new": True, + }, + ) + assertResult( + result, + expected={"value": Eq({"_id": 1, "x": 1}), "ok": Eq(1.0)}, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py new file mode 100644 index 000000000..c15c22e98 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py @@ -0,0 +1,188 @@ +""" +Tests for findAndModify data type coverage: BSON types, null semantics, +numeric equivalence. +""" + +import pytest +from bson import Binary, Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import BSON_TYPE_SAMPLES, BsonType + +NULL_SEMANTICS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query-null-matches-null-and-missing", + docs=[{"_id": 1, "f": None}, {"_id": 2, "f": 10}, {"_id": 3}], + command={ + "query": {"f": None}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 1, "f": None})}, + msg="query {f:null} matches docs where f is null AND where f is missing", + ), + CommandTestCase( + "set-null-distinct-from-unset", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": None}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": None})}, + msg="$set:{f:null} stores null (distinct from removing field)", + ), + CommandTestCase( + "query-false-does-not-match-zero", + docs=[{"_id": 1, "f": 0}, {"_id": 2, "f": False}], + command={ + "query": {"f": False}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 2, "f": False})}, + msg="query {f:false} does NOT match document with f:0", + ), + CommandTestCase( + "query-empty-string-does-not-match-null", + docs=[{"_id": 1, "f": None}, {"_id": 2, "f": ""}], + command={ + "query": {"f": ""}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 2, "f": ""})}, + msg='query {f:""} does NOT match document with f:null', + ), + CommandTestCase( + "decimal128-query-match-and-roundtrip", + docs=[{"_id": 1, "f": Decimal128("123.456")}], + command={ + "query": {"f": Decimal128("123.456")}, + "update": {"$set": {"matched": True}}, + }, + expected={"value": Eq({"_id": 1, "f": Decimal128("123.456")})}, + msg="Decimal128 query matches and preserves type", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NULL_SEMANTICS_TESTS)) +def test_findAndModify_data_types(database_client, collection, test): + """Test findAndModify data type semantics.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize( + "stored,query_value", + [ + pytest.param(1, 1, id="int32_matches_1"), + pytest.param(1, Int64(1), id="int64_matches_1"), + pytest.param(1, 1.0, id="double_matches_1"), + pytest.param(1, Decimal128("1"), id="decimal_matches_1"), + pytest.param(0, 0, id="int32_matches_0"), + pytest.param(0, Int64(0), id="int64_matches_0"), + pytest.param(0, 0.0, id="double_matches_0"), + pytest.param(0, Decimal128("0"), id="decimal_matches_0"), + ], +) +def test_findAndModify_numeric_equivalence(collection, stored, query_value): + """Test numeric equivalence: different numeric types match the same stored value.""" + collection.insert_one({"_id": 1, "f": stored}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"f": query_value}, + "update": {"$set": {"matched": True}}, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "f": stored}, "lastErrorObject": {"n": 1}}) + + +BSON_TYPE_VALUES = [ + pytest.param(BSON_TYPE_SAMPLES[BsonType.DOUBLE], id="double"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.STRING], id="string"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.OBJECT], id="object"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.ARRAY], id="array"), + pytest.param(Binary(b"\x00\x01\x02", 128), id="binary"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.OBJECT_ID], id="objectid"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.BOOL], id="bool"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.DATE], id="date"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.NULL], id="null"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.REGEX], id="regex"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.INT], id="int32"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.TIMESTAMP], id="timestamp"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.LONG], id="int64"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.DECIMAL], id="decimal128"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.MIN_KEY], id="minkey"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.MAX_KEY], id="maxkey"), +] + + +@pytest.mark.parametrize("value", BSON_TYPE_VALUES) +def test_findAndModify_bson_type_query_match(collection, value): + """Test findAndModify query equality match works for each BSON type.""" + collection.insert_one({"_id": 1, "f": value}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"f": value}, + "update": {"$set": {"matched": True}}, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "f": value}, "lastErrorObject": {"n": 1}}) + + +@pytest.mark.parametrize("value", BSON_TYPE_VALUES) +def test_findAndModify_bson_type_set_roundtrip(collection, value): + """Test findAndModify $set stores BSON type and round-trips it.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"f": value}}, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"_id": 1, "f": value}, "lastErrorObject": {"updatedExisting": True}} + ) + + +@pytest.mark.parametrize("value", BSON_TYPE_VALUES) +def test_findAndModify_bson_type_replacement_roundtrip(collection, value): + """Test findAndModify replacement document preserves BSON type on round-trip.""" + collection.insert_one({"_id": 1, "f": "original"}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"_id": 1, "f": value}, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"_id": 1, "f": value}, "lastErrorObject": {"updatedExisting": True}} + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py new file mode 100644 index 000000000..102a71367 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py @@ -0,0 +1,77 @@ +"""Tests for findAndModify with nested dollar-prefixed field names.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.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.property_checks import Eq + +DOLLAR_PREFIXED_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "set_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$x": 1}}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.$x": 99}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$x": 99})}}, + msg="$set on nested dollar-prefixed field should succeed", + ), + CommandTestCase( + "inc_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$count": 5}}], + command={ + "query": {"_id": 1}, + "update": {"$inc": {"a.$count": 1}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$count": 6})}}, + msg="$inc on nested dollar-prefixed field should succeed", + ), + CommandTestCase( + "mul_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$val": 5}}], + command={ + "query": {"_id": 1}, + "update": {"$mul": {"a.$val": 2}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$val": 10})}}, + msg="$mul on nested dollar-prefixed field should succeed", + ), + CommandTestCase( + "max_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$val": 5}}], + command={ + "query": {"_id": 1}, + "update": {"$max": {"a.$val": 10}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$val": 10})}}, + msg="$max on nested dollar-prefixed field should succeed", + ), +] + +ALL_TESTS = DOLLAR_PREFIXED_SUCCESS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_dollar_prefixed_fields(database_client, collection, test): + """Test findAndModify with dollar-prefixed and dotted field names.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + 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/query_and_write/commands/findAndModify/test_findAndModify_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_errors.py new file mode 100644 index 000000000..240ca31af --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_errors.py @@ -0,0 +1,514 @@ +""" +Tests for findAndModify error cases: argument conflicts, immutable field +violations, invalid projections, constraint violations, and arrayFilters errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + DOCUMENT_VALIDATION_FAILURE_ERROR, + DOLLAR_PREFIXED_FIELD_NAME_ERROR, + DUPLICATE_KEY_ERROR, + FAILED_TO_PARSE_ERROR, + IMMUTABLE_FIELD_ERROR, + INVALID_OPTIONS_ERROR, + LET_UNDEFINED_VARIABLE_ERROR, + PROJECT_EXCLUSION_IN_INCLUSION_ERROR, + PROJECT_UNKNOWN_EXPRESSION_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +ARGUMENT_CONFLICT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "neither-update-nor-remove", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with neither update nor remove should fail", + ), + CommandTestCase( + "both-remove-and-update", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "update": {"$set": {"x": 1}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with both remove and update should fail", + ), + CommandTestCase( + "remove-and-new-true", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "new": True, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with remove:true and new:true should fail", + ), + CommandTestCase( + "remove-and-upsert", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "upsert": True, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with remove:true and upsert:true should fail", + ), + CommandTestCase( + "remove-and-update-with-sort", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "update": {"$set": {"x": 1}}, + "sort": {"_id": 1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with both remove and update plus sort should fail", + ), + CommandTestCase( + "remove-update-and-upsert", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "update": {"$set": {"x": 1}}, + "upsert": True, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with remove, update and upsert all set should fail", + ), + CommandTestCase( + "unknown-field", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "unknownField": True, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="findAndModify with unrecognized top-level field should fail", + ), + CommandTestCase( + "mixed-dollar-and-plain-keys", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}, "y": 30}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="update mixing dollar-prefixed and plain keys should fail", + ), + CommandTestCase( + "pipeline-disallowed-stage", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": [{"$match": {"x": 10}}], + }, + error_code=INVALID_OPTIONS_ERROR, + msg="pipeline update with disallowed stage ($match) should fail", + ), +] + +BAD_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hint-nonexistent-index", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": "nonexistent_index", + }, + error_code=BAD_VALUE_ERROR, + msg="hint referencing non-existent index should fail with BadValue", + ), + CommandTestCase( + "maxTimeMS-negative", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "maxTimeMS": -1, + }, + error_code=BAD_VALUE_ERROR, + msg="negative maxTimeMS should fail with BadValue", + ), +] + +PARSE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "maxTimeMS-fractional-double", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "maxTimeMS": 100.5, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="maxTimeMS as fractional double should fail", + ), +] + +IMMUTABLE_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "replacement-change-id", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"_id": 2, "x": 20}, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="replacement attempting to change _id should fail", + ), + CommandTestCase( + "update-change-id-via-operator", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"_id": 2}}, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="changing _id via $set operator should fail", + ), + CommandTestCase( + "setOnInsert-duplicate-id", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 2}, + "update": {"$setOnInsert": {"_id": 1}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with $setOnInsert producing duplicate _id should fail", + ), + CommandTestCase( + "setOnInsert-id-mismatch", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"_id": 2, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with selector _id differing from $setOnInsert _id should fail", + ), + CommandTestCase( + "setOnInsert-document-id-different-values", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"a": 1, "b": 99}, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with document-typed _id having different field values should fail", + ), + CommandTestCase( + "setOnInsert-dotted-id-mismatch", + docs=[], + command={ + "query": {"_id.a": 1}, + "update": {"$setOnInsert": {"_id": {"a": 2}, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with dotted id values differing should fail", + ), + CommandTestCase( + "setOnInsert-array-id-different-order", + docs=[], + command={ + "query": {"_id": [1, 2, 3]}, + "update": {"$setOnInsert": {"_id": [3, 2, 1], "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with array id having different element ordering should fail", + ), +] + +PROJECTION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "projection-mixed-inclusion-exclusion", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"x": 1, "y": 0}, + }, + error_code=PROJECT_EXCLUSION_IN_INCLUSION_ERROR, + msg="fields projection mixing inclusion and exclusion should fail", + ), + CommandTestCase( + "projection-invalid-operator-no-flags", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (no upsert/new flags)", + ), + CommandTestCase( + "projection-invalid-operator-upsert-only", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "upsert": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (upsert only)", + ), + CommandTestCase( + "projection-invalid-operator-new-only", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "new": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (new only)", + ), + CommandTestCase( + "projection-invalid-operator-upsert-and-new", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "upsert": True, + "new": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (upsert + new)", + ), + CommandTestCase( + "projection-invalid-operator-insert-no-match", + docs=[], + command={ + "query": {"_id": 99}, + "update": {"$set": {"x": 20}}, + "upsert": True, + "new": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (upsert insert path)", + ), +] + +DUPLICATE_KEY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "duplicate-key-violation", + docs=[{"_id": 1, "key": "a"}, {"_id": 2, "key": "b"}], + indexes=[IndexModel("key", unique=True)], + command={ + "query": {"_id": 2}, + "update": {"$set": {"key": "a"}}, + }, + error_code=DUPLICATE_KEY_ERROR, + msg="update causing unique index violation should fail with DuplicateKey", + ), +] + +ARGUMENT_ERROR_TESTS: list[CommandTestCase] = ( + ARGUMENT_CONFLICT_TESTS + + BAD_VALUE_TESTS + + PARSE_ERROR_TESTS + + IMMUTABLE_FIELD_TESTS + + PROJECTION_ERROR_TESTS + + DUPLICATE_KEY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_ERROR_TESTS)) +def test_findAndModify_argument_errors(database_client, collection, test): + """Test findAndModify argument validation errors.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +ARRAY_FILTERS_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unreferenced_identifier_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"grades.0": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="arrayFilters identifier not referenced in update should produce an error", + ), + CommandTestCase( + "identifier_no_corresponding_filter_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + }, + error_code=BAD_VALUE_ERROR, + msg="update with $[identifier] but no matching arrayFilters entry should produce an error", + ), + CommandTestCase( + "duplicate_identifier_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}, {"elem": {"$lte": 80}}], + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="two arrayFilters for the same identifier should produce an error", + ), + CommandTestCase( + "with_pipeline_update_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"modified": True}}], + "arrayFilters": [{"elem": {"$gte": 90}}], + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="arrayFilters with aggregation-pipeline update should produce an error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARRAY_FILTERS_ERROR_TESTS)) +def test_findAndModify_array_filters_errors(database_client, collection, test): + """Test findAndModify arrayFilters validation errors.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +DOLLAR_PREFIXED_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "set_top_level_dollar_field_fails", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"$bad": 1}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$set on top-level dollar-prefixed field should fail", + ), + CommandTestCase( + "inc_top_level_dollar_field_fails", + docs=[{"_id": 1, "$val": 10}], + command={ + "query": {"_id": 1}, + "update": {"$inc": {"$val": 1}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$inc on top-level dollar-prefixed field should fail", + ), + CommandTestCase( + "mul_top_level_dollar_field_fails", + docs=[{"_id": 1, "$val": 10}], + command={ + "query": {"_id": 1}, + "update": {"$mul": {"$val": 2}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$mul on top-level dollar-prefixed field should fail", + ), + CommandTestCase( + "max_top_level_dollar_field_fails", + docs=[{"_id": 1, "$val": 10}], + command={ + "query": {"_id": 1}, + "update": {"$max": {"$val": 20}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$max on top-level dollar-prefixed field should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DOLLAR_PREFIXED_ERROR_TESTS)) +def test_findAndModify_dollar_prefixed_errors(database_client, collection, test): + """Test findAndModify dollar-prefixed field validation errors.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_validation_rejects_invalid_write(database_client, request): + """Test bypassDocumentValidation:false rejects non-conforming update.""" + db = database_client + coll_name = f"{request.node.name}_validated" + db.create_collection(coll_name, validator={"$jsonSchema": {"required": ["name"]}}) + coll = db[coll_name] + coll.insert_one({"_id": 1, "name": "test"}) + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "update": {"$unset": {"name": ""}}, + }, + ) + assertFailureCode(result, DOCUMENT_VALIDATION_FAILURE_ERROR) + + +def test_findAndModify_expr_undefined_variable_fails(collection): + """Test $expr referencing undefined variable produces an error.""" + collection.insert_one({"_id": 1, "a": 10}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"$expr": {"$eq": ["$a", "$$undefined_var"]}}, + "update": {"$set": {"x": 1}}, + }, + ) + assertFailureCode(result, LET_UNDEFINED_VARIABLE_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py new file mode 100644 index 000000000..d0df1fbba --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py @@ -0,0 +1,116 @@ +"""Tests for findAndModify with $expr in query and let variables.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.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.property_checks import Eq + +EXPR_LET_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expr_comparison_selects_correct_doc", + docs=[ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 1, "b": 10}, + ], + command={ + "query": {"$expr": {"$gt": ["$a", "$b"]}}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(5), "b": Eq(3)}}, + msg="findAndModify query using $expr with $gt should select correct document", + ), + CommandTestCase( + "expr_with_let_variable", + docs=[ + {"_id": 1, "a": 10}, + {"_id": 2, "a": 20}, + ], + command={ + "query": {"$expr": {"$eq": ["$a", "$$target"]}}, + "update": {"$set": {"found": True}}, + "let": {"target": 20}, + }, + expected={"value": {"_id": Eq(2), "a": Eq(20)}}, + msg="$expr referencing a let variable should select correct document", + ), + CommandTestCase( + "let_variable_in_pipeline_update", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"y": "$$bonus"}}], + "let": {"bonus": 100}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "x": Eq(10), "y": Eq(100)}}, + msg="findAndModify with let variable used in update pipeline should apply variable value", + ), + CommandTestCase( + "expr_with_sort_composes_correctly", + docs=[ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 8, "b": 2}, + {"_id": 3, "a": 1, "b": 10}, + ], + command={ + "query": {"$expr": {"$gt": ["$a", "$b"]}}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": -1}, + }, + # Both _id:1 (a=5>b=3) and _id:2 (a=8>b=2) match; sort desc picks _id:2 + expected={"value": {"_id": Eq(2), "a": Eq(8), "b": Eq(2)}}, + msg="$expr filtering combined with descending sort picks highest _id among matches", + ), + CommandTestCase( + "let_in_replacement_update", + docs=[{"_id": 1, "a": 5}, {"_id": 2, "a": 10}], + command={ + "query": {"$expr": {"$eq": ["$a", "$$val"]}}, + "update": {"a": 99, "replaced": True}, + "let": {"val": 10}, + "new": True, + }, + expected={"value": {"_id": Eq(2), "a": Eq(99), "replaced": Eq(True)}}, + msg="let variable in $expr query with replacement update should select and replace", + ), + CommandTestCase( + "expr_literal_true_matches_all", + docs=[ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 1, "b": 10}, + {"_id": 3, "a": -1, "b": 0}, + ], + command={ + "query": {"$expr": True}, + "update": {"$set": {"touched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(5), "b": Eq(3)}}, + msg="$expr with literal true should match all and return first by sort", + ), +] + +ALL_TESTS = EXPR_LET_SUCCESS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_expr_let(database_client, collection, test): + """Test findAndModify $expr in query and let variables.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + 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/query_and_write/commands/findAndModify/test_findAndModify_projection.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_projection.py new file mode 100644 index 000000000..e6e2e0324 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_projection.py @@ -0,0 +1,170 @@ +""" +Tests for findAndModify fields projection behavior. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.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.property_checks import Eq + +UPDATE_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "new-true-returns-post-image-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"x": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 99})}, + msg="fields projection with new:true returns projection of post-modification doc", + ), + CommandTestCase( + "new-false-returns-pre-image-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"x": 1}, + "new": False, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="fields projection with new:false returns projection of pre-modification doc", + ), + CommandTestCase( + "exclude-id-from-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"_id": 0, "x": 1}, + }, + expected={"value": Eq({"x": 10})}, + msg="fields projection with _id:0 omits _id from returned value", + ), +] + +REMOVE_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "remove-inclusion-projection", + docs=[{"_id": 1, "x": 10, "y": 20, "z": 30}], + command={ + "query": {"_id": 1}, + "remove": True, + "fields": {"x": 1}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="remove with inclusion projection returns _id plus included field", + ), + CommandTestCase( + "remove-exclusion-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "remove": True, + "fields": {"y": 0}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="remove with exclusion projection returns document without excluded field", + ), + CommandTestCase( + "remove-exclude-id-and-field", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "remove": True, + "fields": {"_id": 0, "x": 0}, + }, + expected={"value": Eq({"y": 20})}, + msg="remove with exclusion of both _id and a field", + ), + CommandTestCase( + "remove-combined-query-sort-projection", + docs=[ + {"_id": 1, "x": 5, "y": "a"}, + {"_id": 2, "x": 15, "y": "b"}, + {"_id": 3, "x": 25, "y": "c"}, + ], + command={ + "query": {"x": {"$gt": 10}}, + "remove": True, + "sort": {"x": -1}, + "fields": {"y": 0}, + }, + expected={"value": Eq({"_id": 3, "x": 25})}, + msg="remove combining range query, exclusion projection, and descending sort", + ), +] + +UPSERT_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "projection-on-upsert-new-true", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10, "y": 20}}, + "upsert": True, + "new": True, + "fields": {"x": 1}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="projection on upserted document returns only projected fields", + ), +] + +COMPUTED_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "computed-expression-new-true", + docs=[{"_id": 1, "x": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "fields": {"doubled": {"$multiply": ["$x", 2]}, "_id": 0}, + "new": True, + }, + expected={"value": Eq({"doubled": 20})}, + msg="computed expression in fields evaluates against post-image when new:true", + ), + CommandTestCase( + "computed-expression-new-false", + docs=[{"_id": 1, "x": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "fields": {"doubled": {"$multiply": ["$x", 2]}, "_id": 0}, + "new": False, + }, + expected={"value": Eq({"doubled": 10})}, + msg="computed expression in fields evaluates against pre-image when new:false", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + UPDATE_PROJECTION_TESTS + + REMOVE_PROJECTION_TESTS + + UPSERT_PROJECTION_TESTS + + COMPUTED_PROJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_projection(database_client, collection, test): + """Test findAndModify fields projection behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + 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/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py new file mode 100644 index 000000000..2888b40c6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py @@ -0,0 +1,289 @@ +""" +Tests for findAndModify update modes: operator updates, replacements, pipelines, +arrayFilters, boundary inputs, and input interactions. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +UPDATE_MODE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all-dollar-keys-is-update-operator", + docs=[{"_id": 1, "x": 10, "y": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}, "$inc": {"y": 1}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20, "y": 6})}, + msg="update with all dollar-prefixed keys treated as update-operator form", + ), + CommandTestCase( + "no-dollar-keys-is-replacement", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"z": 30}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "z": 30})}, + msg="update with no dollar-prefixed keys treated as replacement", + ), + CommandTestCase( + "pipeline-references-existing-fields", + docs=[{"_id": 1, "a": 3, "b": 7}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"total": {"$add": ["$a", "$b"]}}}], + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": 3, "b": 7, "total": 10})}, + msg="pipeline update can reference existing field values", + ), +] + +BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty-set-is-noop", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {}}, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "x": 10}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + }, + msg="update {$set:{}} succeeds with no field change", + ), + CommandTestCase( + "empty-replacement", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1}), + "ok": Eq(1.0), + }, + msg="replacement with empty document {} leaves only _id", + ), + CommandTestCase( + "unset-removes-field", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$unset": {"y": ""}}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + "ok": Eq(1.0), + }, + msg="$unset removes field and new:true doc omits it", + ), + CommandTestCase( + "deeply-nested-set-creates-intermediates", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.b.c.d.e": 1}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": {"b": {"c": {"d": {"e": 1}}}}})}, + msg="$set with deeply nested path creates intermediate documents", + ), +] + +DOTTED_PATH_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "set-dotted-index-on-array", + docs=[{"_id": 1, "a": [10, 20, 30]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.0": 99}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": [99, 20, 30]})}, + msg="$set with dotted numeric index on array updates the element at that index", + ), + CommandTestCase( + "set-dotted-index-on-object", + docs=[{"_id": 1, "a": {"0": "old", "1": "keep"}}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.0": "new"}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": {"0": "new", "1": "keep"}})}, + msg="$set with dotted numeric key on object updates the field named '0'", + ), +] + +INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upsert-with-sort-no-match-still-inserts", + docs=[], + command={ + "query": {"_id": 1}, + "sort": {"x": -1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="upsert:true + sort: when no match, insert still occurs", + ), + CommandTestCase( + "projection-on-removed-field-new-true", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$unset": {"x": ""}}, + "fields": {"x": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1})}, + msg="fields projection on a field removed by update (new:true) -- absent", + ), + CommandTestCase( + "atomic-pre-image-consistent", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99, "y": 99}}, + }, + expected={"value": Eq({"_id": 1, "x": 10, "y": 20})}, + msg="findAndModify pre-image is self-consistent", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + UPDATE_MODE_TESTS + BOUNDARY_TESTS + DOTTED_PATH_TESTS + INTERACTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_update_modes(database_client, collection, test): + """Test findAndModify update modes.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_array_filters_updates_matching_elements(collection): + """Test arrayFilters restricts update to matching array elements.""" + collection.insert_one({"_id": 1, "grades": [85, 92, 78, 95]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "grades": [85, 100, 78, 100]}}) + + +def test_findAndModify_array_filters_multiple_identifiers(collection): + """Test arrayFilters with multiple identifiers in same update.""" + collection.insert_one({"_id": 1, "scores": [5, 15, 25, 35]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": { + "$set": {"scores.$[low]": 0, "scores.$[high]": 100}, + }, + "arrayFilters": [{"low": {"$lt": 10}}, {"high": {"$gt": 30}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "scores": [0, 15, 25, 100]}}) + + +def test_findAndModify_positional_operator(collection): + """Test positional $ operator updates first matching array element.""" + collection.insert_one({"_id": 1, "items": [10, 20, 30]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1, "items": 20}, + "update": {"$set": {"items.$": 99}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "items": [10, 99, 30]}}) + + +def test_findAndModify_pipeline_multiple_stages(collection): + """Test pipeline update with multiple stages.""" + collection.insert_one({"_id": 1, "x": 10, "y": 20, "tmp": "remove_me"}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": [ + {"$set": {"sum": {"$add": ["$x", "$y"]}}}, + {"$unset": "tmp"}, + ], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "x": 10, "y": 20, "sum": 30}}) + + +def test_findAndModify_pull(collection): + """Test $pull with findAndModify removes matching elements and returns updated doc.""" + collection.insert_one({"_id": 1, "arr": [1, 2, 3, 2]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$pull": {"arr": 2}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "arr": [1, 3]}}) + + +def test_findAndModify_addToSet(collection): + """Test $addToSet with findAndModify adds element and returns updated doc.""" + collection.insert_one({"_id": 1, "arr": ["a"]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$addToSet": {"arr": "b"}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "arr": ["a", "b"]}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py new file mode 100644 index 000000000..b6f799dd8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py @@ -0,0 +1,461 @@ +""" +Tests for findAndModify upsert behavior: lastErrorObject, $setOnInsert, +id consistency, auto-increment patterns. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.error_codes import IMMUTABLE_FIELD_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, Exists, IsType, NotExists + +EQUALITY_SEEDING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "equality-query-seeds-field", + docs=[], + command={ + "query": {"x": 10}, + "update": {"$set": {"y": 20}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": Exists(), "x": Eq(10), "y": Eq(20)}}, + msg="upsert with equality predicate seeds field value into inserted doc", + ), + CommandTestCase( + "non-equality-does-not-seed", + docs=[], + command={ + "query": {"x": {"$gt": 5}}, + "update": {"$set": {"y": 20}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": Exists(), "x": NotExists(), "y": Eq(20)}}, + msg="upsert with non-equality operator ($gt) does NOT seed field", + ), + CommandTestCase( + "no-id-auto-creates-id", + docs=[], + command={ + "query": {"x": 10}, + "update": {"$set": {"x": 10, "y": 20}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": IsType("objectId"), "x": Eq(10), "y": Eq(20)}}, + msg="upsert with no _id in query/update auto-creates an ObjectId _id", + ), +] + +REPLACEMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "replacement-inserts-replacement", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"_id": 1, "z": 99}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "z": 99})}, + msg="upsert with replacement update inserts the replacement document", + ), +] + +LAST_ERROR_OBJECT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lastErrorObject-upserted-id", + docs=[], + command={ + "query": {"_id": 42}, + "update": {"$set": {"x": 1}}, + "upsert": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 42}), + }, + msg="lastErrorObject.upserted is the _id of the new document", + ), + CommandTestCase( + "new-false-returns-null-value-key", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": False, + }, + expected={ + "value": Eq(None), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="upsert insert with new=false returns value key set to null", + ), + CommandTestCase( + "upsert-false-nonexistent-returns-null", + docs=[], + command={ + "query": {"_id": 999}, + "update": {"$set": {"x": 10}}, + "upsert": False, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="upsert=false on non-existent document returns null and inserts nothing", + ), +] + +SET_ON_INSERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "setOnInsert-sets-id-on-upsert", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="upsert with $setOnInsert inserts new document using _id from query", + ), + CommandTestCase( + "setOnInsert-noop-when-match-exists", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"x": 99}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="$setOnInsert is a no-op when matching doc already exists", + ), + CommandTestCase( + "setOnInsert-document-id-match-succeeds", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"a": 1, "b": 2}, "x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": {"a": 1, "b": 2}, "x": 10})}, + msg="upsert succeeds when selector and $setOnInsert specify identical document ids", + ), + CommandTestCase( + "setOnInsert-dotted-id-match-succeeds", + docs=[], + command={ + "query": {"_id.a": 1}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": {"a": 1}, "x": 10})}, + msg="upsert with dotted _id query constructs _id from dotted notation", + ), + CommandTestCase( + "setOnInsert-selector-has-id-setOnInsert-omits", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="upsert succeeds when selector specifies _id and $setOnInsert omits it", + ), + CommandTestCase( + "setOnInsert-omits-id-sets-unique", + docs=[], + command={ + "query": {"x": 999}, + "update": {"$setOnInsert": {"_id": 42, "y": 1}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 42, "x": 999, "y": 1})}, + msg="upsert succeeds when selector omits _id and $setOnInsert sets a unique _id", + ), + CommandTestCase( + "setOnInsert-range-filter-no-match-inserts", + docs=[], + command={ + "query": {"_id": {"$gt": 100}}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": IsType("objectId"), "x": Eq(10)}}, + msg="upsert with range filter on _id matching no docs inserts using $setOnInsert", + ), + CommandTestCase( + "setOnInsert-range-filter-match-updates", + docs=[{"_id": 200, "x": 5}], + command={ + "query": {"_id": {"$gt": 100}}, + "update": {"$setOnInsert": {"x": 99}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 200, "x": 5})}, + msg="upsert with range filter matching existing doc is noop for $setOnInsert", + ), + CommandTestCase( + "setOnInsert-subset-id-subfields-consistent", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"a": 1, "b": 2}, "x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": {"a": 1, "b": 2}, "x": 10})}, + msg="upsert with $setOnInsert _id sub-fields consistent with selector succeeds", + ), + CommandTestCase( + "setOnInsert-id-subfields-different-order", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"b": 2, "a": 1}, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with $setOnInsert _id sub-fields in different order fails", + ), +] + +AUTO_INCREMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "auto-increment-pattern", + docs=[], + command={ + "query": {"_id": "counter"}, + "update": {"$inc": {"val": 1}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": "counter", "val": 1})}, + msg="upsert with $inc builds auto-incrementing counter", + ), +] + +PIPELINE_UPSERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "pipeline-upsert-no-match-seeds-from-equality-query", + docs=[], + command={ + "query": {"_id": 1, "base": 10}, + "update": [{"$set": {"doubled": {"$multiply": ["$base", 2]}}}], + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "base": 10, "doubled": 20}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="pipeline upsert with no match: pipeline computes against the " + "equality-query-seeded document", + ), + CommandTestCase( + "pipeline-upsert-no-match-non-equality-field-absent-during-compute", + docs=[], + command={ + "query": {"_id": 1, "base": {"$gt": 5}}, + "update": [{"$set": {"doubled": {"$multiply": [{"$ifNull": ["$base", 0]}, 2]}}}], + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "doubled": 0}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="pipeline upsert with no match: non-equality ($gt) query field is NOT " + "seeded, so the pipeline sees it as missing", + ), + CommandTestCase( + "pipeline-upsert-existing-match-computes-from-stored-doc", + docs=[{"_id": 1, "base": 7}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"doubled": {"$multiply": ["$base", 2]}}}], + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "base": 7, "doubled": 14}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + }, + msg="pipeline upsert with existing match: pipeline computes against the " "stored document", + ), +] + +UPSERT_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upsert-new-true-id-exclusion-projection", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10, "y": 20}}, + "upsert": True, + "new": True, + "fields": {"_id": 0, "x": 1}, + }, + expected={"value": Eq({"x": 10})}, + msg="upsert + new:true + id-exclusion projection returns doc without _id", + ), + CommandTestCase( + "upsert-new-false-excludes-all-returns-null-value", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": False, + "fields": {"x": 1}, + }, + expected={ + "value": Eq(None), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="upsert + new:false returns null even with projection (no pre-image)", + ), + CommandTestCase( + "upsert-replacement-new-true-projection", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"_id": 1, "a": 1, "b": 2}, + "upsert": True, + "new": True, + "fields": {"a": 1}, + }, + expected={"value": Eq({"_id": 1, "a": 1})}, + msg="upsert + replacement + new:true + projection returns projected post-image", + ), + CommandTestCase( + "upsert-replacement-new-false", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"_id": 1, "a": 1, "b": 2}, + "upsert": True, + "new": False, + }, + expected={ + "value": Eq(None), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="upsert + replacement + new:false returns null (pre-image of insert is nothing)", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + EQUALITY_SEEDING_TESTS + + REPLACEMENT_TESTS + + LAST_ERROR_OBJECT_TESTS + + SET_ON_INSERT_TESTS + + AUTO_INCREMENT_TESTS + + PIPELINE_UPSERT_TESTS + + UPSERT_PROJECTION_TESTS + + [ + CommandTestCase( + "upsert-updates-existing-doc", + docs=[{"_id": 1, "x": 10, "y": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "x": 99, "y": 5}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + }, + msg="upsert with existing match updates doc instead of inserting", + ), + CommandTestCase( + "setOnInsert-combined-with-set", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}, "$setOnInsert": {"y": 99}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10, "y": 99})}, + msg="upsert with $set + $setOnInsert applies both on insert", + ), + ] +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_upsert(database_client, collection, test): + """Test findAndModify upsert behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_upsert_unique_index_updates_instead_of_dup(collection): + """Test upsert with unique index: second upsert updates rather than duplicates.""" + collection.create_index("key", unique=True) + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"key": "abc"}, + "update": {"$set": {"val": 1}}, + "upsert": True, + }, + ) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"key": "abc"}, + "update": {"$set": {"val": 2}}, + "upsert": True, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"key": "abc", "val": 2}, "lastErrorObject": {"updatedExisting": True}} + ) + + +def test_findAndModify_upsert_array_filters_no_matching_array(collection): + """Test upsert + arrayFilters when inserted doc has no matching array elements.""" + collection.insert_one({"_id": 1, "grades": [50, 60, 70]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "grades": [50, 60, 70]}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py deleted file mode 100644 index 261b480f1..000000000 --- a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Tests for $expr in findAndModify command contexts. -""" - -from documentdb_tests.framework.assertions import assertSuccess -from documentdb_tests.framework.executor import execute_command - -BASIC_DOCS = [ - {"_id": 1, "a": 5, "b": 3}, - {"_id": 2, "a": 1, "b": 10}, - {"_id": 3, "a": -1, "b": 0}, -] - - -def test_expr_in_find_and_modify(collection): - """Test $expr in findAndModify query.""" - collection.insert_many(BASIC_DOCS) - result = execute_command( - collection, - { - "findAndModify": collection.name, - "query": {"$expr": {"$gt": ["$a", "$b"]}}, - "update": {"$set": {"modified": True}}, - "sort": {"_id": 1}, - }, - ) - assertSuccess( - result, {"_id": 1, "a": 5, "b": 3}, raw_res=True, transform=lambda r: r.get("value") - ) - - -def test_expr_findandmodify_literal_true(collection): - """Test $expr with literal true in findAndModify — matches all, returns first by sort.""" - collection.insert_many(BASIC_DOCS) - result = execute_command( - collection, - { - "findAndModify": collection.name, - "query": {"$expr": True}, - "update": {"$set": {"touched": True}}, - "sort": {"_id": 1}, - }, - ) - assertSuccess( - result, {"_id": 1, "a": 5, "b": 3}, raw_res=True, transform=lambda r: r.get("value") - ) From 977fd3f6ab102b0c1a638f372dd4fdee9adb9922 Mon Sep 17 00:00:00 2001 From: Ian Forster Date: Thu, 2 Jul 2026 12:31:31 -0700 Subject: [PATCH 22/51] Feature: query-and-write writeConcern parameter (#620) Signed-off-by: Ian Forster Signed-off-by: Victor Tsang Co-authored-by: Victor Tsang --- .../write_concern/test_smoke_write_concern.py | 36 ++- .../test_write_concern_acceptance.py | 235 +++++++++++++++ .../test_write_concern_behavior.py | 186 ++++++++++++ ...test_write_concern_bson_type_validation.py | 112 +++++++ .../test_write_concern_errors.py | 281 ++++++++++++++++++ .../test_write_concern_replica_set.py | 53 ++++ 6 files changed, 888 insertions(+), 15 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py index a0864ac37..410e337da 100644 --- a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_smoke_write_concern.py @@ -1,27 +1,33 @@ -""" -Smoke test for writeConcern. +"""writeConcern smoke test: a basic insert with w:1 succeeds.""" -Tests basic writeConcern functionality. -""" +from typing import Any, Dict, cast import pytest -from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq pytestmark = pytest.mark.smoke - -def test_smoke_write_concern(collection): - """Test basic writeConcern behavior.""" - result = execute_command( - collection, - { - "insert": collection.name, +SMOKE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "insert_w1", + command={ "documents": [{"_id": 1, "name": "test"}], "writeConcern": {"w": 1}, }, - ) + expected={"ok": Eq(1.0), "n": Eq(1)}, + msg="Should support writeConcern", + ), +] - expected = {"ok": 1.0, "n": 1} - assertSuccessPartial(result, expected, msg="Should support writeConcern") + +@pytest.mark.parametrize("test", pytest_params(SMOKE_TESTS)) +def test_smoke_write_concern(collection, test: CommandTestCase): + """Test basic writeConcern behavior.""" + insert_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"insert": collection.name, **insert_body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py new file mode 100644 index 000000000..b1243980d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_acceptance.py @@ -0,0 +1,235 @@ +"""writeConcern acceptance: valid w/j/wtimeout/provenance values and their +combinations, plus writeConcern:null behaving like an omitted writeConcern. +""" + +from typing import Any, Dict, cast + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, +) + +W_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_0", + command={"updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": 0}}, + expected={"ok": Eq(1.0)}, + msg="w:0 should be accepted.", + ), + CommandTestCase( + "w_double_coerced", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1.0}, + }, + expected={"ok": Eq(1.0)}, + msg="w:1.0 should coerce and be accepted.", + ), + CommandTestCase( + "w_int64_coerced", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Int64(1)}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Int64 should coerce and be accepted.", + ), + CommandTestCase( + "w_decimal128_coerced", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Decimal128("1")}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Decimal128 should coerce and be accepted.", + ), + CommandTestCase( + "w_int64_0", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Int64(0)}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Int64(0) should be accepted.", + ), + CommandTestCase( + "w_negative_zero", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": -0.0}, + }, + expected={"ok": Eq(1.0)}, + msg="w:-0.0 should be accepted.", + ), + CommandTestCase( + "w_fractional_0_5", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0.5}, + }, + expected={"ok": Eq(1.0)}, + msg="w:0.5 should be accepted.", + ), + CommandTestCase( + "w_fractional_1_5", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1.5}, + }, + expected={"ok": Eq(1.0)}, + msg="w:1.5 should be accepted.", + ), +] + + +WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "int32_max", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": INT32_MAX}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout INT32_MAX ok.", + ), + CommandTestCase( + "int32_min", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": INT32_MIN}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout INT32_MIN ok.", + ), + CommandTestCase( + "negative_inf", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": FLOAT_NEGATIVE_INFINITY}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout -Infinity ok.", + ), + CommandTestCase( + "decimal128_neg_inf", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": DECIMAL128_NEGATIVE_INFINITY}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout Decimal128 -Infinity ok.", + ), + CommandTestCase( + "zero", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": 0}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout 0 ok.", + ), + CommandTestCase( + "negative", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": -1}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout negative ok.", + ), + CommandTestCase( + "with_w0", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0, "wtimeout": 5_000}, + }, + expected={"ok": Eq(1.0)}, + msg="wtimeout with w:0 ok.", + ), +] + + +# Sub-fields compose in one writeConcern document. +COMBINATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_three", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "j": True, "wtimeout": 5_000}, + }, + expected={"ok": Eq(1.0)}, + msg="w + j + wtimeout together should be accepted.", + ), +] + + +# provenance acceptance (a representative value plus null). +PROVENANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clientSupplied", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": "clientSupplied"}, + }, + expected={"ok": Eq(1.0)}, + msg="provenance:'clientSupplied' should be accepted.", + ), + CommandTestCase( + "null", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": None}, + }, + expected={"ok": Eq(1.0)}, + msg="provenance:null should be accepted.", + ), +] + +WRITE_CONCERN_ACCEPTANCE_TESTS = ( + W_ACCEPTANCE_TESTS + WTIMEOUT_ACCEPTANCE_TESTS + COMBINATION_TESTS + PROVENANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_ACCEPTANCE_TESTS)) +def test_write_concern_accepted(collection, test: CommandTestCase): + """Test writeConcern accepts valid sub-field values and combinations.""" + collection.insert_one({"_id": 1, "a": 0}) + update_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"update": collection.name, **update_body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) + + +def test_write_concern_null_equivalent_to_omitted(collection): + """Test writeConcern null produces the same response as omitting writeConcern.""" + collection.insert_many([{"_id": 1, "a": 0}, {"_id": 2, "a": 0}]) + omitted = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + }, + ) + explicit_null = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 2}, "u": {"$set": {"a": 1}}}], + "writeConcern": None, + }, + ) + expected = {k: omitted[k] for k in ("ok", "n", "nModified") if k in omitted} + assertSuccessPartial( + explicit_null, + expected, + msg="update with writeConcern:null should match an omitted writeConcern.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py new file mode 100644 index 000000000..ef6e8915b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_behavior.py @@ -0,0 +1,186 @@ +"""writeConcern behavior: w:0 unacknowledged writes, j:true overriding w:0, +findAndModify return semantics, and ordered interaction. + +The "still performs the write" checks are hand-written (not CommandTestCase +rows) because they verify the effect with a second find command. +""" + +from typing import Any, Dict, cast + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists + +BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "update_j_true_overrides_w0", + docs=[{"_id": 1}], + command={ + "verb": "update", + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 0, "j": True}, + }, + expected={"ok": Eq(1.0)}, + msg="update with j:true should override w:0.", + ), + CommandTestCase( + "delete_j_true_overrides_w0", + docs=[{"_id": 99}], + command={ + "verb": "delete", + "deletes": [{"q": {"_id": 99}, "limit": 1}], + "writeConcern": {"w": 0, "j": True}, + }, + expected={"ok": Eq(1.0)}, + msg="delete with j:true should override w:0.", + ), + CommandTestCase( + "findAndModify_j_true_overrides_w0", + docs=[{"_id": 1}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 1}}, + "writeConcern": {"w": 0, "j": True}, + }, + expected={"ok": Eq(1.0)}, + msg="findAndModify with j:true should override w:0.", + ), + CommandTestCase( + "findAndModify_w0_accepts_and_performs_write", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": True, + "writeConcern": {"w": 0}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(99)}}, + msg="findAndModify with w:0 should still perform the write.", + ), + CommandTestCase( + "findAndModify_new_true", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": True, + "writeConcern": {"w": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(99)}}, + msg="findAndModify new:true should return modified doc.", + ), + CommandTestCase( + "findAndModify_new_false", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "update": {"$set": {"a": 99}}, + "new": False, + "writeConcern": {"w": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(0)}}, + msg="findAndModify new:false should return original doc.", + ), + CommandTestCase( + "findAndModify_remove", + docs=[{"_id": 1, "a": 0}], + command={ + "verb": "findAndModify", + "query": {"_id": 1}, + "remove": True, + "writeConcern": {"w": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(0)}}, + msg="findAndModify remove:true should return removed doc.", + ), + CommandTestCase( + "update_ordered_true", + docs=[{"_id": 1}], + command={ + "verb": "update", + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "ordered": True, + "writeConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="update with ordered:true and writeConcern should succeed.", + ), + CommandTestCase( + "update_ordered_false", + docs=[{"_id": 1}], + command={ + "verb": "update", + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "ordered": False, + "writeConcern": {"w": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="update with ordered:false and writeConcern should succeed.", + ), + # w:0 suppresses the per-operation error that w:1 surfaces. The w:1 "surfaces" + # counterpart lives in test_write_concern_errors.py (w1_surfaces_operation_error). + # multi:true with a replacement doc is the invalid operation under test. + CommandTestCase( + "w0_suppresses_operation_error", + docs=[{"_id": 1, "a": 1}], + command={ + "verb": "update", + "updates": [{"q": {}, "u": {"a": 2}, "multi": True}], + "writeConcern": {"w": 0}, + }, + expected={"writeErrors": NotExists()}, + msg="update with w:0 should suppress the operation error.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BEHAVIOR_TESTS)) +def test_write_concern_behavior(collection, test: CommandTestCase): + """Test single-command writeConcern behaviors.""" + collection = test.prepare(collection.database, collection) + body = dict(cast(Dict[str, Any], test.command)) + verb = body.pop("verb") + result = execute_command(collection, {verb: collection.name, **body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) + + +def test_update_w0_performs_write(collection): + """Test update with w:0 still performs the write.""" + collection.insert_one({"_id": 1, "a": 0}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 99}}}], + "writeConcern": {"w": 0}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertResult( + result, + expected=[{"_id": 1, "a": 99}], + msg="update with w:0 should still perform the write.", + ) + + +def test_delete_w0_performs_delete(collection): + """Test delete with w:0 still performs the delete.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "delete": collection.name, + "deletes": [{"q": {"_id": 1}, "limit": 1}], + "writeConcern": {"w": 0}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertResult(result, expected=[], msg="delete with w:0 should still perform the delete.") diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py new file mode 100644 index 000000000..24583765d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_bson_type_validation.py @@ -0,0 +1,112 @@ +"""writeConcern BSON type validation: the writeConcern field accepts only a +document or null, and the w/j/wtimeout sub-fields accept their supported BSON +types; all other types are rejected. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import FAILED_TO_PARSE_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command + +WRITE_CONCERN_PARAMS = [ + BsonTypeTestCase( + id="write_concern_field", + msg="writeConcern should reject non-document types", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"w": 1}}, + ), +] + +SUB_FIELD_PARAMS = [ + BsonTypeTestCase( + id="w", + msg="w should accept numbers, 'majority', and tagged objects", + valid_types=[ + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.STRING, + BsonType.OBJECT, + ], + skip_rejection_types=[BsonType.NULL], + default_error_code=FAILED_TO_PARSE_ERROR, + valid_inputs={ + BsonType.INT: 1, + BsonType.LONG: Int64(1), + BsonType.DOUBLE: 1.0, + BsonType.DECIMAL: Decimal128("1"), + BsonType.STRING: "majority", + BsonType.OBJECT: {"dc1": 1}, + }, + ), + BsonTypeTestCase( + id="j", + msg="j should accept boolean, numeric types, and null", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="wtimeout", + msg="wtimeout should accept all BSON types", + valid_types=list(BsonType), + default_error_code=FAILED_TO_PARSE_ERROR, + valid_inputs={BsonType.LONG: Int64(5_000)}, + ), +] + + +def _build_command(collection_name, spec, sample_value): + """Build an update command placing the sample value per the spec.""" + if spec.id == "write_concern_field": + write_concern = sample_value + elif spec.id == "w": + write_concern = {"w": sample_value} + else: + write_concern = {"w": 1, spec.id: sample_value} + return { + "update": collection_name, + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": write_concern, + } + + +_ALL_PARAMS = WRITE_CONCERN_PARAMS + SUB_FIELD_PARAMS + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(_ALL_PARAMS) +) +def test_write_concern_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test the writeConcern field and sub-fields accept their supported BSON types.""" + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertSuccessPartial(result, {"ok": 1.0}, msg=f"{spec.id} should accept {bson_type.value}") + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(_ALL_PARAMS) +) +def test_write_concern_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test the writeConcern field and sub-fields reject unsupported BSON types.""" + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertResult( + result, + error_code=spec.expected_code(bson_type), + msg=f"{spec.id} should reject {bson_type.value}", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py new file mode 100644 index 000000000..e3ce10cc1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_errors.py @@ -0,0 +1,281 @@ +"""writeConcern rejection: invalid w/j/wtimeout values, case-sensitive w strings, +invalid provenance, and unknown fields, plus the w:1 case where a per-operation +error surfaces. Each is expected to fail with a specific error code. BSON-type +rejection lives in test_write_concern_bson_type_validation.py. +""" + +from typing import Any, Dict, cast + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_MAX, + INT32_OVERFLOW, + INT64_MAX, + INT64_MIN, +) + +# These cases are only rejected on standalone; a quorum target treats them as +# custom tag names (valid), so they are deselected there. +_STANDALONE_ONLY = (pytest.mark.requires(quorum_write_concern=False),) + +WRITE_CONCERN_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_null", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": None}}, + error_code=BAD_VALUE_ERROR, + msg="w:null should be rejected on standalone.", + marks=_STANDALONE_ONLY, + ), + CommandTestCase( + "w_negative", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": -1}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w:-1 should be rejected.", + ), + CommandTestCase( + "w_exceeds_50", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": 51}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w:51 should be rejected.", + ), + CommandTestCase( + "w_float_nan", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": FLOAT_NAN}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as NaN should be rejected.", + ), + CommandTestCase( + "w_float_neg_nan", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": FLOAT_NEGATIVE_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as -NaN should be rejected.", + ), + CommandTestCase( + "w_decimal128_nan", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 NaN should be rejected.", + ), + CommandTestCase( + "w_decimal128_neg_nan", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_NEGATIVE_NAN}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 -NaN should be rejected.", + ), + CommandTestCase( + "w_float_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": FLOAT_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as +Infinity should be rejected.", + ), + CommandTestCase( + "w_float_neg_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": FLOAT_NEGATIVE_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as -Infinity should be rejected.", + ), + CommandTestCase( + "w_decimal128_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 +Infinity should be rejected.", + ), + CommandTestCase( + "w_decimal128_neg_inf", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": DECIMAL128_NEGATIVE_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Decimal128 -Infinity should be rejected.", + ), + CommandTestCase( + "w_tagged_non_numeric", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": {"dc1": "hello"}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="w tagged object with non-numeric value should be rejected.", + ), + CommandTestCase( + "w_int64_max", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": INT64_MAX}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Int64 max should be rejected.", + ), + CommandTestCase( + "w_int64_min", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": INT64_MIN}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="w as Int64 min should be rejected.", + ), + CommandTestCase( + "w_tagged_empty_object", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": {}}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="empty object w should be rejected (tagged write concern requires tags).", + ), + CommandTestCase( + "w_tagged_nested_object", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": {"dc1": {"nested": 1}}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="tagged w with nested object value should be rejected.", + ), + CommandTestCase( + "wtimeout_int64_overflow", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": Int64(INT32_MAX + 1)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Int64 wtimeout exceeding INT32_MAX should be rejected.", + ), + CommandTestCase( + "wtimeout_double_overflow", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": float(INT32_OVERFLOW)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="double wtimeout exceeding INT32_MAX should be rejected.", + ), + CommandTestCase( + "wtimeout_decimal128_overflow", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": Decimal128(str(INT32_OVERFLOW))}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="Decimal128 wtimeout exceeding INT32_MAX should be rejected.", + ), + CommandTestCase( + "wtimeout_float_infinity", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "wtimeout": FLOAT_INFINITY}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="+Infinity wtimeout (exceeds INT32_MAX) should be rejected.", + ), + # Property [Unknown Field Rejection]: unrecognized fields in writeConcern are rejected. + CommandTestCase( + "unknown_field", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "unknownField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="writeConcern should reject unrecognized fields.", + ), + CommandTestCase( + "provenance_invalid_string", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="provenance with an unknown string value should be rejected.", + ), + CommandTestCase( + "provenance_wrong_type", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 1, "provenance": 42}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="provenance with a non-string value should be rejected.", + ), + # A valid writeConcern (w:1) surfaces a per-operation error: multi:true with a + # replacement doc is invalid. The w:0 "suppresses" counterpart lives in + # test_write_concern_behavior.py (w0_suppresses_operation_error). + CommandTestCase( + "w1_surfaces_operation_error", + docs=[{"_id": 1, "a": 1}], + command={ + "updates": [{"q": {}, "u": {"a": 2}, "multi": True}], + "writeConcern": {"w": 1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="update with w:1 should surface the operation error.", + ), + # w "majority" is case-sensitive and an empty string is a custom tag; both are + # only rejected on standalone (see _STANDALONE_ONLY). + CommandTestCase( + "w_wrong_case_Majority", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": "Majority"}, + }, + error_code=BAD_VALUE_ERROR, + msg="w:'Majority' should be rejected (case-sensitive).", + marks=_STANDALONE_ONLY, + ), + CommandTestCase( + "w_all_caps_MAJORITY", + command={ + "updates": [{"q": {}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": "MAJORITY"}, + }, + error_code=BAD_VALUE_ERROR, + msg="w:'MAJORITY' should be rejected (case-sensitive).", + marks=_STANDALONE_ONLY, + ), + CommandTestCase( + "w_empty_string", + command={"updates": [{"q": {}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": ""}}, + error_code=BAD_VALUE_ERROR, + msg="w:'' (empty string custom tag) should be rejected on standalone.", + marks=_STANDALONE_ONLY, + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_REJECTION_TESTS)) +def test_write_concern_rejected(collection, test: CommandTestCase): + """Test writeConcern rejects invalid sub-field values and unknown fields.""" + collection = test.prepare(collection.database, collection) + update_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"update": collection.name, **update_body}) + assertResult(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py new file mode 100644 index 000000000..b8d181115 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/write_concern/test_write_concern_replica_set.py @@ -0,0 +1,53 @@ +"""writeConcern w values > 1 (up to 50), accepted only on a replica set. +Deselected on standalone targets. +""" + +from typing import Any, Dict, cast + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.requires(quorum_write_concern=True) + + +W_REPLICA_SET_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_2", + command={"updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], "writeConcern": {"w": 2}}, + expected={"ok": Eq(1.0)}, + msg="w:2 should be accepted on a replica set.", + ), + CommandTestCase( + "w_50_max", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": 50}, + }, + expected={"ok": Eq(1.0)}, + msg="w:50 (max) should be accepted on a replica set.", + ), + CommandTestCase( + "w_int64_50", + command={ + "updates": [{"q": {"_id": 1}, "u": {"$set": {"a": 1}}}], + "writeConcern": {"w": Int64(50)}, + }, + expected={"ok": Eq(1.0)}, + msg="w as Int64(50) should be accepted on a replica set.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(W_REPLICA_SET_TESTS)) +def test_write_concern_w_replica_set(collection, test: CommandTestCase): + """Test writeConcern accepts w values requiring a replica set.""" + collection.insert_one({"_id": 1, "a": 0}) + update_body = cast(Dict[str, Any], test.command) + result = execute_command(collection, {"update": collection.name, **update_body}) + assertResult(result, expected=test.expected, msg=test.msg, raw_res=True) From b72f7ed1f5a811453837e17e1b65386ab2198c9b Mon Sep 17 00:00:00 2001 From: Ian Forster Date: Thu, 2 Jul 2026 14:13:15 -0700 Subject: [PATCH 23/51] Add compactStructuredEncryptionData administration command tests (#625) Signed-off-by: Alina (Xi) Li --- .../compatibility/tests/system/__init__.py | 0 .../tests/system/administration/__init__.py | 0 .../administration/commands/__init__.py | 0 .../__init__.py | 0 ...tStructuredEncryptionData_core_behavior.py | 80 +++++++++++ ...pactStructuredEncryptionData_edge_cases.py | 70 +++++++++ ...actStructuredEncryptionData_error_cases.py | 92 ++++++++++++ ...ructuredEncryptionData_field_validation.py | 109 ++++++++++++++ ...tStructuredEncryptionData_qe_collection.py | 134 ++++++++++++++++++ ...t_smoke_compactStructuredEncryptionData.py | 13 +- documentdb_tests/framework/error_codes.py | 2 + 11 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_error_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_field_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_qe_collection.py diff --git a/documentdb_tests/compatibility/tests/system/__init__.py b/documentdb_tests/compatibility/tests/system/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/__init__.py b/documentdb_tests/compatibility/tests/system/administration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_core_behavior.py new file mode 100644 index 000000000..5e3e38ad7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_core_behavior.py @@ -0,0 +1,80 @@ +"""Tests for compactStructuredEncryptionData core behavior. + +Verifies the command correctly rejects non-encrypted collections with error 6346807 +and handles non-existent collections. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + NAMESPACE_NOT_FOUND_ERROR, + NOT_ENCRYPTED_COLLECTION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + +# Property [Non-Encrypted Rejection]: compactStructuredEncryptionData rejects +# collections that are not configured for Queryable Encryption with error 6346807. +CORE_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_compaction_tokens", + docs=[], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted collection" + " with empty tokens", + ), + CommandTestCase( + "non_empty_compaction_tokens", + docs=[], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"field": b"\x00\x01\x02"}, + }, + error_code=NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted collection with tokens", + ), + CommandTestCase( + "collection_with_documents", + docs=[{"_id": 1, "name": "test"}, {"_id": 2, "name": "data"}], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted collection with documents", + ), + CommandTestCase( + "nonexistent_collection", + command=lambda ctx: { + "compactStructuredEncryptionData": "nonexistent_collection_xyz", + "compactionTokens": {}, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="compactStructuredEncryptionData should error on non-existent collection", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CORE_BEHAVIOR_TESTS)) +def test_compactStructuredEncryptionData_core_behavior(database_client, collection, test): + """Test compactStructuredEncryptionData core behavior on non-encrypted collections.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_edge_cases.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_edge_cases.py new file mode 100644 index 000000000..4667ec137 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_edge_cases.py @@ -0,0 +1,70 @@ +"""Tests for compactStructuredEncryptionData edge cases. + +Covers collection name edge cases. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + NAMESPACE_NOT_FOUND_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + +# Property [Collection Name Edge Cases]: compactStructuredEncryptionData handles +# special collection name patterns correctly. +COLLECTION_NAME_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "system_prefix", + command=lambda ctx: { + "compactStructuredEncryptionData": "system.buckets.test", + "compactionTokens": {}, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="compactStructuredEncryptionData should reject system.* prefix" + " collection names with namespace-not-found", + ), + CommandTestCase( + "dotted_name", + command=lambda ctx: { + "compactStructuredEncryptionData": "a.b.c", + "compactionTokens": {}, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="compactStructuredEncryptionData should reject multi-segment" + " dotted names with namespace-not-found", + ), + CommandTestCase( + "dollar_prefix", + command=lambda ctx: { + "compactStructuredEncryptionData": "$myCollection", + "compactionTokens": {}, + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="compactStructuredEncryptionData should reject dollar-prefixed" + " collection names with namespace-not-found", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COLLECTION_NAME_TESTS)) +def test_compactStructuredEncryptionData_collection_name_edge_cases( + database_client, collection, test +): + """Test compactStructuredEncryptionData rejects special collection name patterns.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_error_cases.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_error_cases.py new file mode 100644 index 000000000..d7b1e07ab --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_error_cases.py @@ -0,0 +1,92 @@ +"""Tests for compactStructuredEncryptionData error cases. + +Covers unrecognized fields and collection type variants (views, capped). +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + NOT_ENCRYPTED_COLLECTION_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.target_collection import CappedCollection, ViewCollection + +pytestmark = pytest.mark.admin + +# Property [Unrecognized Field Rejection]: compactStructuredEncryptionData rejects +# commands with unrecognized fields. +UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "extra_field", + docs=[], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="compactStructuredEncryptionData should reject unrecognized fields", + ), + CommandTestCase( + "similar_field_name", + docs=[], + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + "compactionToken": {}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="compactStructuredEncryptionData should reject fields with similar names", + ), +] + +# Property [Collection Type Rejection]: compactStructuredEncryptionData rejects +# views and returns non-encrypted error for capped collections. +COLLECTION_VARIANT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "on_view", + docs=[{"_id": 1}], + target_collection=ViewCollection(), + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="compactStructuredEncryptionData should reject views", + ), + CommandTestCase( + "on_capped_collection", + docs=[{"_id": 1}], + target_collection=CappedCollection(), + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted capped collection", + ), +] + +ERROR_TESTS = UNRECOGNIZED_FIELD_TESTS + COLLECTION_VARIANT_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_compactStructuredEncryptionData_errors(database_client, collection, test): + """Test compactStructuredEncryptionData error conditions.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_field_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_field_validation.py new file mode 100644 index 000000000..b21e85008 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_field_validation.py @@ -0,0 +1,109 @@ +"""Tests for compactStructuredEncryptionData command field validation. + +Covers collection name type validation (§19 representative case), +compactionTokens BSON type rejection, and missing field errors. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import ( + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + NOT_ENCRYPTED_COLLECTION_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.test_constants import BsonType + +pytestmark = pytest.mark.admin + +# Property [CompactionTokens Type Rejection]: compactStructuredEncryptionData rejects +# non-document types for the compactionTokens field. +BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="compactionTokens_type", + msg="compactionTokens should reject non-document types", + keyword="compactionTokens", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(BSON_TYPE_PARAMS) +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_compactStructuredEncryptionData_rejects_invalid_compactionTokens_type( + collection, bson_type, sample_value, spec +): + """Test compactStructuredEncryptionData rejects invalid BSON types for compactionTokens.""" + cmd = { + "compactStructuredEncryptionData": collection.name, + "compactionTokens": sample_value, + } + result = execute_command(collection, cmd) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_compactStructuredEncryptionData_accepts_valid_compactionTokens_type( + collection, bson_type, sample_value, spec +): + """Test compactStructuredEncryptionData accepts document type for compactionTokens. + + The command accepts the type but fails because the collection is not encrypted. + Error 6346807 confirms the type was accepted and processing continued. + """ + collection.insert_one({"_id": 1}) + cmd = { + "compactStructuredEncryptionData": collection.name, + "compactionTokens": sample_value, + } + result = execute_command(collection, cmd) + assertFailureCode( + result, + NOT_ENCRYPTED_COLLECTION_ERROR, + msg=spec.msg, + ) + + +def test_compactStructuredEncryptionData_rejects_non_string_collection_name(collection): + """Test compactStructuredEncryptionData rejects non-string collection name.""" + cmd = {"compactStructuredEncryptionData": 1, "compactionTokens": {}} + result = execute_command(collection, cmd) + assertFailureCode( + result, + INVALID_NAMESPACE_ERROR, + msg="compactStructuredEncryptionData should reject non-string collection name", + ) + + +def test_compactStructuredEncryptionData_rejects_empty_collection_name(collection): + """Test compactStructuredEncryptionData rejects empty string collection name.""" + cmd = {"compactStructuredEncryptionData": "", "compactionTokens": {}} + result = execute_command(collection, cmd) + assertFailureCode( + result, + INVALID_NAMESPACE_ERROR, + msg="compactStructuredEncryptionData should reject empty collection name", + ) + + +def test_compactStructuredEncryptionData_missing_compactionTokens(collection): + """Test compactStructuredEncryptionData requires compactionTokens field.""" + collection.insert_one({"_id": 1}) + cmd = {"compactStructuredEncryptionData": collection.name} + result = execute_command(collection, cmd) + assertFailureCode( + result, + MISSING_FIELD_ERROR, + msg="compactStructuredEncryptionData should error when compactionTokens is missing", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_qe_collection.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_qe_collection.py new file mode 100644 index 000000000..af5c5483f --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_compactStructuredEncryptionData_qe_collection.py @@ -0,0 +1,134 @@ +"""Tests for compactStructuredEncryptionData on Queryable Encryption collections. + +Verifies the success path, missing-token rejection, and token content validation +on collections that are actually configured for Queryable Encryption. These tests +require a replica set (QE collection creation fails on standalone with 6346402). +""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from bson import Binary + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.error_codes import MISSING_COMPACT_TOKEN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(queryable_encryption=True) + + +@pytest.fixture() +def qe_collection(collection): + """Create a Queryable Encryption collection with one encrypted field.""" + db = collection.database + qe_name = f"{collection.name}_qe" + db.command( + "create", + qe_name, + encryptedFields={ + "fields": [ + { + "path": "ssn", + "bsonType": "string", + "keyId": Binary(uuid4().bytes, 4), + } + ] + }, + ) + yield db[qe_name] + db.drop_collection(qe_name) + + +# Property [Success Path]: compactStructuredEncryptionData succeeds on a QE collection +# with a valid compaction token and returns stats. +SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "valid_token", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"ssn": Binary(b"\x00" * 32, 0)}, + }, + expected={"ok": 1.0}, + msg="compactStructuredEncryptionData should succeed with valid token on QE collection.", + ), + CommandTestCase( + "null_token_value", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"ssn": None}, + }, + expected={"ok": 1.0}, + msg="compactStructuredEncryptionData should accept null token value on QE collection.", + ), + CommandTestCase( + "nested_document_token_value", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"ssn": {"nested": b"\x00\x01"}}, + }, + expected={"ok": 1.0}, + msg="compactStructuredEncryptionData should accept nested document token value" + " on QE collection.", + ), +] + +# Property [Token Rejection]: compactStructuredEncryptionData rejects tokens that do not +# match an encrypted path on a QE collection. +ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "missing_token_empty_tokens", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {}, + }, + error_code=MISSING_COMPACT_TOKEN_ERROR, + msg="compactStructuredEncryptionData should reject empty compactionTokens" + " on QE collection.", + ), + CommandTestCase( + "empty_string_key", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"": Binary(b"\x00" * 32, 0)}, + }, + error_code=MISSING_COMPACT_TOKEN_ERROR, + msg="compactStructuredEncryptionData should reject empty-string token key" + " that does not match an encrypted path.", + ), + CommandTestCase( + "dot_notation_key", + command=lambda ctx: { + "compactStructuredEncryptionData": ctx.collection, + "compactionTokens": {"a.b": Binary(b"\x00" * 32, 0)}, + }, + error_code=MISSING_COMPACT_TOKEN_ERROR, + msg="compactStructuredEncryptionData should reject dot-notation token key" + " that does not match an encrypted path.", + ), +] + +QE_SUCCESS_TESTS: list[CommandTestCase] = SUCCESS_TESTS +QE_ERROR_TESTS: list[CommandTestCase] = ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(QE_SUCCESS_TESTS)) +def test_compactStructuredEncryptionData_qe_success(qe_collection, test): + """Test compactStructuredEncryptionData succeeds on QE collection.""" + ctx = CommandContext.from_collection(qe_collection) + result = execute_command(qe_collection, test.build_command(ctx)) + assertSuccessPartial(result, test.expected, msg=test.msg) + + +@pytest.mark.parametrize("test", pytest_params(QE_ERROR_TESTS)) +def test_compactStructuredEncryptionData_qe_error(qe_collection, test): + """Test compactStructuredEncryptionData rejects invalid tokens on QE collection.""" + ctx = CommandContext.from_collection(qe_collection) + result = execute_command(qe_collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_smoke_compactStructuredEncryptionData.py b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_smoke_compactStructuredEncryptionData.py index 7f8c7cb93..077f2ffec 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_smoke_compactStructuredEncryptionData.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/compactStructuredEncryptionData/test_smoke_compactStructuredEncryptionData.py @@ -1,12 +1,12 @@ -""" -Smoke test for compactStructuredEncryptionData command. +"""Smoke test for compactStructuredEncryptionData command. Tests basic compactStructuredEncryptionData functionality. """ import pytest -from documentdb_tests.framework.assertions import assertFailure +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import NOT_ENCRYPTED_COLLECTION_ERROR from documentdb_tests.framework.executor import execute_command pytestmark = pytest.mark.smoke @@ -20,5 +20,8 @@ def test_smoke_compactStructuredEncryptionData(collection): collection, {"compactStructuredEncryptionData": collection.name, "compactionTokens": {}} ) - expected = {"code": 6346807, "msg": "Target namespace is not an encrypted collection"} - assertFailure(result, expected, msg="Should support compactStructuredEncryptionData command") + assertFailureCode( + result, + NOT_ENCRYPTED_COLLECTION_ERROR, + msg="compactStructuredEncryptionData should reject non-encrypted collection", + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 423b3fe65..53963b346 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -507,6 +507,7 @@ ENCRYPTED_FIELD_DUPLICATE_PATH_ERROR = 6338402 ENCRYPTED_FIELD_UNSUPPORTED_TYPE_ERROR = 6338406 ENCRYPTED_FIELD_VIEW_TIMESERIES_ERROR = 6346401 +NOT_ENCRYPTED_COLLECTION_ERROR = 6346807 ENCRYPTED_FIELD_CAPPED_ERROR = 6367301 APPLYOPS_PRECONDITION_NOT_SUPPORTED_ERROR = 6711600 APPLYOPS_ALWAYS_UPSERT_NOT_SUPPORTED_ERROR = 6711601 @@ -516,6 +517,7 @@ WILDCARD_MULTIPLE_FIELDS_ERROR = 7246201 WILDCARD_STRING_TYPE_ERROR = 7246202 OUT_TIMESERIES_COLLECTION_TYPE_ERROR = 7268700 +MISSING_COMPACT_TOKEN_ERROR = 7294900 OUT_TIMESERIES_OPTIONS_MISMATCH_ERROR = 7406103 SORT_DUPLICATE_KEY_ERROR = 7472500 N_ACCUMULATOR_INVALID_N_ERROR = 7548606 From 369c88a6a96b4fcfc1841e28294f6c8f2011738c Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 2 Jul 2026 14:23:57 -0700 Subject: [PATCH 24/51] Add collMod command tests (#626) Signed-off-by: Daniel Frankcom --- .../collMod/test_collMod_capped_max.py | 335 +++++++++++++ .../collMod/test_collMod_capped_size.py | 347 ++++++++++++++ .../collMod/test_collMod_change_streams.py | 235 +++++++++ .../commands/collMod/test_collMod_comment.py | 91 ++++ .../commands/collMod/test_collMod_dry_run.py | 278 +++++++++++ .../collMod/test_collMod_index_argument.py | 155 ++++++ .../collMod/test_collMod_index_expire.py | 418 ++++++++++++++++ .../collMod/test_collMod_index_hidden.py | 274 +++++++++++ .../collMod/test_collMod_index_identifier.py | 321 +++++++++++++ .../test_collMod_index_prepare_unique.py | 317 +++++++++++++ .../collMod/test_collMod_index_unique.py | 241 ++++++++++ .../collMod/test_collMod_interactions.py | 417 ++++++++++++++++ .../commands/collMod/test_collMod_pipeline.py | 412 ++++++++++++++++ .../collMod/test_collMod_read_concern.py | 201 ++++++++ .../collMod/test_collMod_target_name.py | 151 ++++++ .../test_collMod_time_series_bucketing.py | 369 +++++++++++++++ .../test_collMod_time_series_document.py | 171 +++++++ .../test_collMod_time_series_expire.py | 371 +++++++++++++++ .../test_collMod_time_series_granularity.py | 190 ++++++++ .../test_collMod_validation_level_action.py | 254 ++++++++++ .../collMod/test_collMod_validator.py | 447 ++++++++++++++++++ .../commands/collMod/test_collMod_view_on.py | 184 +++++++ .../collMod/test_collMod_write_concern.py | 344 ++++++++++++++ documentdb_tests/framework/error_codes.py | 1 + documentdb_tests/framework/preconditions.py | 2 + documentdb_tests/framework/test_constants.py | 1 + 26 files changed, 6527 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py new file mode 100644 index 000000000..c6f54d567 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py @@ -0,0 +1,335 @@ +"""Tests for collMod cappedMax and cappedSize/cappedMax coexistence on capped collections.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + NAMESPACE_NOT_FOUND_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 +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + ExistingDatabase, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_OVERFLOW, +) + +# Property [cappedMax Numeric Type Acceptance]: cappedMax accepts any numeric type. +COLLMOD_CAPPED_MAX_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_numeric_int32", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 cappedMax", + ), + CommandTestCase( + "max_numeric_int64", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Int64(1_000)}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int64 cappedMax", + ), + CommandTestCase( + "max_numeric_double", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000.0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a double cappedMax", + ), + CommandTestCase( + "max_numeric_decimal", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Decimal128("1000")}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a decimal128 cappedMax", + ), +] + +# Property [cappedMax Null No-Op]: a null cappedMax is accepted as a no-op. +COLLMOD_CAPPED_MAX_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_null", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null cappedMax as a no-op", + ), +] + +# Property [cappedMax No Lower Bound]: any value at or below 0, NaN, or +# -Infinity is accepted and means "no document limit"; cappedMax has no lower +# bound, unlike cappedSize. +COLLMOD_CAPPED_MAX_NO_LOWER_BOUND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a cappedMax of 0 as no document limit", + ), + CommandTestCase( + "max_negative", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": -1}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a negative cappedMax as no document limit", + ), + CommandTestCase( + "max_nan", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": FLOAT_NAN}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a NaN cappedMax as no document limit", + ), + CommandTestCase( + "max_negative_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": FLOAT_NEGATIVE_INFINITY}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a -Infinity cappedMax as no document limit", + ), +] + +# Property [cappedMax Upper Boundary]: the boundary value INT32_MAX is accepted. +COLLMOD_CAPPED_MAX_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_upper_boundary_int32_max", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": INT32_MAX}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the upper boundary cappedMax of INT32_MAX", + ), +] + +# Property [cappedMax Fractional Coercion]: a fractional cappedMax is coerced to +# an integer before the range check, with a double truncating toward zero and a +# decimal128 using banker's (round-half-to-even) rounding; a value that lands +# below the exclusive upper bound is accepted. +COLLMOD_CAPPED_MAX_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_double_truncates_toward_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": INT32_MAX + 0.5}, + expected={"ok": Eq(1.0)}, + msg="collMod should truncate a fractional double cappedMax toward zero to INT32_MAX, " + "below the exclusive upper bound", + ), + CommandTestCase( + "max_decimal_bankers_rounds_down", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Decimal128(f"{INT32_MAX}.4")}, + expected={"ok": Eq(1.0)}, + msg="collMod should banker's-round a fractional decimal128 cappedMax down to INT32_MAX, " + "below the exclusive upper bound", + ), +] + +# Property [cappedMax Type Rejection]: any non-numeric cappedMax type produces a +# TypeMismatch error. +COLLMOD_CAPPED_MAX_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"max_type_reject_{tid}", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "cappedMax": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} cappedMax as a non-numeric type", + ) + for tid, val in [ + ("string", "1000"), + ("bool_true", True), + ("bool_false", False), + ("array", [1000]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [cappedMax Strict Upper Bound]: a value at or above the upper bound +# produces a BadValue error, since the bound is exclusive unlike cappedSize's +# inclusive bound, including a decimal128 that banker's-rounds up onto the bound +# and +Infinity which coerces above the bound. +COLLMOD_CAPPED_MAX_HIGH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_high_at_bound", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": INT32_OVERFLOW}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a cappedMax of 2^31 as at or above the strict upper bound", + ), + CommandTestCase( + "max_high_decimal_bankers_rounds_onto_bound", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Decimal128(f"{INT32_MAX}.5")}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a decimal128 cappedMax that banker's-rounds half-to-even up " + "onto the strict upper bound", + ), + CommandTestCase( + "max_high_positive_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": FLOAT_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a +Infinity cappedMax, which coerces to INT64_MAX, " + "as above the upper bound", + ), +] + +# Property [cappedMax Target Collection Restrictions]: cappedMax is rejected on +# any non-capped target: a regular or clustered collection produces an +# InvalidOptions error, a non-existent collection produces a NamespaceNotFound +# error, and the oplog produces an InvalidOptions error. +COLLMOD_CAPPED_MAX_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_target_regular", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on a regular collection", + ), + CommandTestCase( + "max_target_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on a clustered collection", + ), + CommandTestCase( + "max_target_nonexistent", + docs=None, + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="collMod should reject cappedMax on a non-existent collection", + ), + CommandTestCase( + "max_target_oplog", + target_collection=ExistingDatabase(db_name="local"), + docs=None, + command=lambda ctx: {"collMod": "oplog.rs", "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on the oplog", + marks=(pytest.mark.requires(oplog=True),), + ), +] + +# Property [cappedMax View Crash]: applying cappedMax to a view must not crash +# the engine and must return a clean InvalidOptions error; the reference engine +# is skipped because it crashes (SIGSEGV) on this input. +COLLMOD_CAPPED_MAX_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_target_view", + target_collection=ViewCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on a view with a clean error and not crash", + marks=( + pytest.mark.engine_xcrash( + engine="mongodb", + reason="Server crashes (SIGSEGV) when cappedMax is applied to a view", + ), + ), + ), +] + +# Property [cappedSize And cappedMax Coexistence]: cappedSize and cappedMax, the +# two capped-group sub-options, apply together in one command on a capped +# collection, each taking effect independently. +COLLMOD_CAPPED_SIZE_AND_MAX_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_and_max_together", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": 16384, + "cappedMax": 99, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply cappedSize and cappedMax together in one command", + ), +] + +COLLMOD_CAPPED_MAX_TESTS: list[CommandTestCase] = ( + COLLMOD_CAPPED_MAX_NUMERIC_TESTS + + COLLMOD_CAPPED_MAX_NULL_TESTS + + COLLMOD_CAPPED_MAX_NO_LOWER_BOUND_TESTS + + COLLMOD_CAPPED_MAX_BOUNDARY_TESTS + + COLLMOD_CAPPED_MAX_FRACTIONAL_TESTS + + COLLMOD_CAPPED_MAX_TYPE_ERROR_TESTS + + COLLMOD_CAPPED_MAX_HIGH_ERROR_TESTS + + COLLMOD_CAPPED_MAX_TARGET_ERROR_TESTS + + COLLMOD_CAPPED_MAX_VIEW_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_AND_MAX_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_CAPPED_MAX_TESTS)) +def test_collMod_capped_max(database_client, collection, test): + """Test collMod cappedMax acceptance, rejection, and coexistence with cappedSize.""" + 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/collMod/test_collMod_capped_size.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py new file mode 100644 index 000000000..6723a3b6b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py @@ -0,0 +1,347 @@ +"""Tests for collMod cappedSize on capped collections.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + NAMESPACE_NOT_FOUND_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 +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + ExistingDatabase, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + CAPPED_SIZE_LIMIT_BYTES, + DECIMAL128_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [cappedSize Numeric Type Acceptance]: cappedSize accepts any numeric type. +COLLMOD_CAPPED_SIZE_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_numeric_int32", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 cappedSize", + ), + CommandTestCase( + "size_numeric_int64", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": Int64(100_000)}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int64 cappedSize", + ), + CommandTestCase( + "size_numeric_double", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000.0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a double cappedSize", + ), + CommandTestCase( + "size_numeric_decimal", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": Decimal128("100000")}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a decimal128 cappedSize", + ), +] + +# Property [cappedSize Null No-Op]: a null cappedSize is accepted as a no-op. +COLLMOD_CAPPED_SIZE_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_null", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null cappedSize as a no-op", + ), +] + +# Property [cappedSize Range Boundaries]: the inclusive lower and upper +# boundaries are both accepted. +COLLMOD_CAPPED_SIZE_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_lower_boundary_1", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 1}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the lower boundary cappedSize of 1", + ), + CommandTestCase( + "size_upper_boundary_max", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": CAPPED_SIZE_LIMIT_BYTES}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the upper boundary cappedSize of 2^50", + ), +] + +# Property [cappedSize Fractional Coercion]: a fractional cappedSize is coerced +# to an integer before the range check, with a double truncating toward zero and +# a decimal128 using banker's (round-half-to-even) rounding; a value that lands +# at or above the lower boundary is accepted. +COLLMOD_CAPPED_SIZE_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_double_truncates_to_one", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 1.5}, + expected={"ok": Eq(1.0)}, + msg="collMod should truncate a fractional double cappedSize toward zero to the " + "lower boundary", + ), + CommandTestCase( + "size_decimal_bankers_rounds_up_to_one", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": Decimal128("0.7")}, + expected={"ok": Eq(1.0)}, + msg="collMod should banker's-round a fractional decimal128 cappedSize up to the " + "lower boundary", + ), +] + +# Property [cappedSize Type Rejection]: any non-numeric cappedSize type produces +# a TypeMismatch error. +COLLMOD_CAPPED_SIZE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"size_type_reject_{tid}", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "cappedSize": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} cappedSize as a non-numeric type", + ) + for tid, val in [ + ("string", "8192"), + ("bool_true", True), + ("bool_false", False), + ("array", [8192]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [cappedSize Below Lower Bound]: a value below the inclusive lower +# bound after coercion produces a BadValue error, whether a double truncates +# toward zero or a decimal128 banker's-rounds down to zero. +COLLMOD_CAPPED_SIZE_LOW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_low_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 0}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a cappedSize of 0 as below the lower bound", + ), + CommandTestCase( + "size_low_double_truncates_to_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 0.7}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a double cappedSize that truncates toward zero to 0 " + "as below the lower bound", + ), + CommandTestCase( + "size_low_decimal_bankers_rounds_to_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": DECIMAL128_HALF}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a decimal128 cappedSize that banker's-rounds half-to-even " + "to 0 as below the lower bound", + ), + CommandTestCase( + "size_low_negative_fraction_truncates_to_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": -0.5}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a negative fractional cappedSize that truncates toward zero " + "to 0 as below the lower bound", + ), + CommandTestCase( + "size_low_nan", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": FLOAT_NAN}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a NaN cappedSize, which coerces to 0, as below the lower bound", + ), + CommandTestCase( + "size_low_negative_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": FLOAT_NEGATIVE_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a -Infinity cappedSize, which coerces to INT64_MIN, " + "as below the lower bound", + ), +] + +# Property [cappedSize Above Upper Bound]: a value above the inclusive upper +# bound produces a BadValue error, including a decimal128 that banker's-rounds up +# over the bound and +Infinity which coerces above the bound. +COLLMOD_CAPPED_SIZE_HIGH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_high_above_max", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": CAPPED_SIZE_LIMIT_BYTES + 1}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a cappedSize just above the 2^50 upper bound", + ), + CommandTestCase( + "size_high_decimal_bankers_rounds_over_bound", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": Decimal128(f"{CAPPED_SIZE_LIMIT_BYTES}.7"), + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a decimal128 cappedSize that banker's-rounds up over the " + "upper bound", + ), + CommandTestCase( + "size_high_positive_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": FLOAT_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a +Infinity cappedSize, which coerces to INT64_MAX, " + "as above the upper bound", + ), +] + +# Property [cappedSize Target Collection Restrictions]: cappedSize is rejected on +# any non-capped target: a regular or clustered collection produces an +# InvalidOptions error, a non-existent collection produces a NamespaceNotFound +# error, and the oplog produces an InvalidOptions error. +COLLMOD_CAPPED_SIZE_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_target_regular", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on a regular collection", + ), + CommandTestCase( + "size_target_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on a clustered collection", + ), + CommandTestCase( + "size_target_nonexistent", + docs=None, + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="collMod should reject cappedSize on a non-existent collection", + ), + CommandTestCase( + "size_target_oplog", + target_collection=ExistingDatabase(db_name="local"), + docs=None, + command=lambda ctx: {"collMod": "oplog.rs", "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on the oplog", + marks=(pytest.mark.requires(oplog=True),), + ), +] + +# Property [cappedSize View Crash]: applying cappedSize to a view must not crash +# the engine and must return a clean InvalidOptions error; the reference engine +# is skipped because it crashes (SIGSEGV) on this input. +COLLMOD_CAPPED_SIZE_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_target_view", + target_collection=ViewCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on a view with a clean error and not crash", + marks=( + pytest.mark.engine_xcrash( + engine="mongodb", + reason="Server crashes (SIGSEGV) when cappedSize is applied to a view", + ), + ), + ), +] + +COLLMOD_CAPPED_SIZE_TESTS: list[CommandTestCase] = ( + COLLMOD_CAPPED_SIZE_NUMERIC_TESTS + + COLLMOD_CAPPED_SIZE_NULL_TESTS + + COLLMOD_CAPPED_SIZE_BOUNDARY_TESTS + + COLLMOD_CAPPED_SIZE_FRACTIONAL_TESTS + + COLLMOD_CAPPED_SIZE_TYPE_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_LOW_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_HIGH_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_TARGET_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_VIEW_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_CAPPED_SIZE_TESTS)) +def test_collMod_capped_size(database_client, collection, test): + """Test collMod cappedSize acceptance and rejection on capped collections.""" + 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/collMod/test_collMod_change_streams.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py new file mode 100644 index 000000000..458434d03 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py @@ -0,0 +1,235 @@ +"""Tests for the collMod changeStreamPreAndPostImages option.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + MISSING_FIELD_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.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [changeStreamPreAndPostImages Success]: a null value is accepted as +# an omitted field, and an object with a boolean enabled sub-field is accepted +# for either truth value on a regular collection. +COLLMOD_CHANGE_STREAM_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": None, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null changeStreamPreAndPostImages as an omitted field", + ), + CommandTestCase( + "enabled_true", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": True}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept changeStreamPreAndPostImages with enabled true", + ), + CommandTestCase( + "enabled_false", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": False}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept changeStreamPreAndPostImages with enabled false", + ), +] + +# Property [changeStreamPreAndPostImages Top-Level Type Rejection]: a +# changeStreamPreAndPostImages value that is neither an object nor null produces +# a TypeMismatch error. +COLLMOD_CHANGE_STREAM_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"toplevel_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} changeStreamPreAndPostImages as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [{"enabled": True}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [changeStreamPreAndPostImages Missing enabled]: a +# changeStreamPreAndPostImages object whose enabled sub-field is absent or null +# produces a FailedToParse error because enabled is required and a null value is +# treated as missing rather than as a type mismatch. +COLLMOD_CHANGE_STREAM_MISSING_ENABLED_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "missing_enabled", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {}, + }, + error_code=MISSING_FIELD_ERROR, + msg="collMod should reject changeStreamPreAndPostImages with no enabled sub-field", + ), + CommandTestCase( + "enabled_null", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": None}, + }, + error_code=MISSING_FIELD_ERROR, + msg="collMod should treat a null enabled as a missing required sub-field", + ), +] + +# Property [changeStreamPreAndPostImages Unknown Sub-Field]: a +# changeStreamPreAndPostImages object containing an unrecognized sub-field +# produces an UnknownField error, and that error fires even when the required +# enabled sub-field is also present. +COLLMOD_CHANGE_STREAM_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field_only", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"bogus": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown changeStreamPreAndPostImages sub-field", + ), + CommandTestCase( + "unknown_field_with_enabled", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"bogus": 1, "enabled": True}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown sub-field before the missing-enabled check", + ), +] + +# Property [changeStreamPreAndPostImages enabled Type Rejection]: an enabled +# sub-field whose value is neither a boolean nor absent produces a TypeMismatch +# error. +COLLMOD_CHANGE_STREAM_ENABLED_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"enabled_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} enabled value as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("array", [True]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [changeStreamPreAndPostImages Unsupported Collection Type]: applying +# changeStreamPreAndPostImages to a view or a time series collection produces an +# InvalidOptions error regardless of the enabled truth value. +COLLMOD_CHANGE_STREAM_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{target_id}_enabled_{enabled_id}", + docs=[], + target_collection=target, + command=lambda ctx, e=enabled: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": e}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject changeStreamPreAndPostImages on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] + for enabled_id, enabled in [("true", True), ("false", False)] +] + +COLLMOD_CHANGE_STREAM_ERROR_TESTS: list[CommandTestCase] = ( + COLLMOD_CHANGE_STREAM_TYPE_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_MISSING_ENABLED_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_UNKNOWN_FIELD_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_ENABLED_TYPE_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_UNSUPPORTED_TARGET_ERROR_TESTS +) + +COLLMOD_CHANGE_STREAM_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_CHANGE_STREAM_SUCCESS_TESTS + COLLMOD_CHANGE_STREAM_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_CHANGE_STREAM_ALL_TESTS)) +def test_collMod_change_streams(database_client, collection, test): + """Test collMod changeStreamPreAndPostImages option 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/collMod/test_collMod_comment.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py new file mode 100644 index 000000000..3401ba6c4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py @@ -0,0 +1,91 @@ +"""Tests for the collMod comment option.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import 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, NotExists +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [comment Type Acceptance]: a comment of any BSON type representable +# by pymongo is accepted without changing the command result and is never echoed +# back in the response. +COLLMOD_COMMENT_TYPE_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "comment": v, + }, + expected={"ok": Eq(1.0), "comment": NotExists()}, + msg=f"collMod should accept a {tid} comment without echoing it", + ) + for tid, val in [ + ("string", "a note"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, "two", {"three": 3}]), + ("object", {"reason": "audit"}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [comment Does Not Suppress Errors]: a comment paired with an +# otherwise-invalid option does not suppress the option's error. +COLLMOD_COMMENT_NO_SUPPRESS_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_suppress_index_type_error", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "comment": "a note", + "index": "not_an_object", + }, + error_code=TYPE_MISMATCH_ERROR, + msg="collMod should still reject an invalid index when a comment is present", + ), +] + +COLLMOD_COMMENT_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_COMMENT_TYPE_ACCEPTANCE_TESTS + COLLMOD_COMMENT_NO_SUPPRESS_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_COMMENT_ALL_TESTS)) +def test_collMod_comment(database_client, collection, test): + """Test collMod comment option 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/collMod/test_collMod_dry_run.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py new file mode 100644 index 000000000..332359917 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py @@ -0,0 +1,278 @@ +"""Tests for collMod dryRun behavior.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CANNOT_CONVERT_INDEX_TO_UNIQUE_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, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [dryRun Truthy Checks Without Converting]: a bool true or any numeric +# type that coerces to true (any nonzero value, including negatives, NaN, and +# Infinity) runs the unique conversion as a dry run, validating without +# converting, so the result omits the unique_new echo that an actual conversion +# would produce. +COLLMOD_DRY_RUN_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": v, + }, + expected={"ok": Eq(1.0), "unique_new": NotExists(), "unique_old": NotExists()}, + msg=f"collMod should treat a {tid} dryRun as a dry run that checks without converting", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [dryRun Falsy Performs Conversion]: a bool false, any numeric zero +# (including negative zero), or null disables dry run, so the unique conversion +# is actually performed and the result echoes unique_new true. +COLLMOD_DRY_RUN_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": v, + }, + expected={"ok": Eq(1.0), "unique_new": Eq(True)}, + msg=f"collMod should treat a {tid} dryRun as disabled and perform the conversion", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + ("null", None), + ] +] + +# Property [dryRun Falsy Accepted Without A Unique Conversion]: a falsy dryRun +# (bool false, numeric zero, or null) is accepted with ok:1.0 even when no unique +# conversion is present (whether paired with a non-unique modification such as +# hidden, which still applies, or with no index modification at all), unlike a +# truthy dryRun, which requires a unique conversion. +COLLMOD_DRY_RUN_FALSY_NO_CONVERSION_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"falsy_no_conversion_hidden_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "dryRun": v, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg=f"collMod should accept a {tid} dryRun on a non-unique modification and apply it", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ("null", None), + ] + ], + *[ + CommandTestCase( + f"falsy_no_conversion_no_index_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "dryRun": v, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {tid} dryRun with no index modification present", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ] + ], +] + +COLLMOD_DRY_RUN_SUCCESS_TESTS: list[CommandTestCase] = ( + COLLMOD_DRY_RUN_TRUTHY_TESTS + + COLLMOD_DRY_RUN_FALSY_TESTS + + COLLMOD_DRY_RUN_FALSY_NO_CONVERSION_TESTS +) + +# Property [dryRun Type Rejection]: a dryRun value that is neither a bool nor a +# numeric type is rejected as a type mismatch, since only bool and numeric +# values can be coerced to the dry-run flag. +COLLMOD_DRY_RUN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} dryRun value as a type mismatch", + ) + for tid, val in [ + ("string", "yes"), + ("array", [True]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [dryRun Truthy Requires A Unique Conversion]: a truthy dryRun with no +# index modification, or with an index modification that is not a unique +# conversion (an identify-only index or a non-unique change such as hidden), is +# rejected as an invalid option because dry run mode validates a pending unique +# conversion and has nothing to check otherwise. +COLLMOD_DRY_RUN_REQUIRES_CONVERSION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_index_modification", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: {"collMod": ctx.collection, "dryRun": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy dryRun with no index modification as an " + "invalid option", + ), + CommandTestCase( + "identify_only_index", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1"}, + "dryRun": True, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy dryRun with an identify-only index as an " + "invalid option", + ), + CommandTestCase( + "non_unique_modification", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "dryRun": True, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy dryRun with a non-unique index " + "modification as an invalid option", + ), +] + +# Property [dryRun Truthy Reports Conversion Violations]: a truthy dryRun on a +# unique conversion of an index whose documents contain duplicate values is +# rejected because the conversion would violate uniqueness, reported without +# converting. +COLLMOD_DRY_RUN_DUPLICATE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_conversion_with_duplicates", + indexes=[IndexModel([("a", 1)], name="a_1")], + # Documents that share a value on the indexed field so a unique + # conversion has a violation to report. + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 1}], + setup=lambda coll: execute_command( + coll, + {"collMod": coll.name, "index": {"name": "a_1", "prepareUnique": True}}, + ), + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": True, + }, + error_code=CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + msg="collMod should reject a dry-run unique conversion that has duplicate " + "values without converting", + ), +] + +COLLMOD_DRY_RUN_ERROR_TESTS: list[CommandTestCase] = ( + COLLMOD_DRY_RUN_TYPE_ERROR_TESTS + + COLLMOD_DRY_RUN_REQUIRES_CONVERSION_ERROR_TESTS + + COLLMOD_DRY_RUN_DUPLICATE_ERROR_TESTS +) + +COLLMOD_DRY_RUN_TESTS: list[CommandTestCase] = ( + COLLMOD_DRY_RUN_SUCCESS_TESTS + COLLMOD_DRY_RUN_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_DRY_RUN_TESTS)) +def test_collMod_dry_run(database_client, collection, test): + """Test collMod dryRun behavior.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py new file mode 100644 index 000000000..7e4818c7b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py @@ -0,0 +1,155 @@ +"""Tests for collMod index argument presence, type, structure, and applicability.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_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.property_checks import Eq, NotExists +from documentdb_tests.framework.target_collection import ViewCollection + +# Property [Index Null No-Op]: an index value of null is accepted and treated +# as an omitted field, yielding a no-op success with no index modification +# echoed in the result. +COLLMOD_INDEX_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_no_op", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": None}, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_new": NotExists(), + "expireAfterSeconds_old": NotExists(), + "hidden_new": NotExists(), + "hidden_old": NotExists(), + "prepareUnique_new": NotExists(), + "prepareUnique_old": NotExists(), + "unique_new": NotExists(), + "unique_old": NotExists(), + }, + msg="collMod should accept a null index as an omitted no-op", + ), +] + +# Property [Index Non-Document Type Rejection]: a non-document, non-null value +# for index produces a type-mismatch error. +COLLMOD_INDEX_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "index": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} index value as a type mismatch", + ) + for tid, val in [ + ("string", "a_1"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [{"name": "a_1"}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Empty Index Document Rejection]: an empty index document specifies +# neither an index name nor a key pattern and is rejected as an invalid option. +COLLMOD_INDEX_EMPTY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject an empty index document as an invalid option", + ), +] + +# Property [Unknown Index Sub-Field Rejection]: an unrecognized sub-field inside +# the index document is rejected as an unrecognized field. +COLLMOD_INDEX_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_subfield", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": {"name": "a_1", "bogus": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown index sub-field as an unrecognized field", + ), +] + +# Property [Index Unsupported Collection Type Rejection]: an index modification +# applied to a view is rejected as an invalid option, since the index option is +# not supported on a view. +COLLMOD_INDEX_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_index_not_supported", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject an index modification on a view as an invalid option", + ), +] + +COLLMOD_INDEX_ARGUMENT_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_NULL_TESTS + + COLLMOD_INDEX_TYPE_ERROR_TESTS + + COLLMOD_INDEX_EMPTY_ERROR_TESTS + + COLLMOD_INDEX_UNKNOWN_FIELD_ERROR_TESTS + + COLLMOD_INDEX_VIEW_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_ARGUMENT_TESTS)) +def test_collMod_index_argument(database_client, collection, test): + """Test collMod index argument acceptance and structural rejection.""" + 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/collMod/test_collMod_index_expire.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py new file mode 100644 index 000000000..218ea1881 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py @@ -0,0 +1,418 @@ +"""Tests for collMod index expireAfterSeconds.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + 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, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_INT64_OVERFLOW, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_OVERFLOW, + INT64_ZERO, +) + +# Property [Index expireAfterSeconds Numeric Coercion]: setting +# index.expireAfterSeconds on an existing TTL index accepts any numeric type and +# coerces it to the new TTL, with a double truncating toward zero and a +# decimal128 using banker's (round-half-to-even) rounding, echoing the prior TTL +# as expireAfterSeconds_old and the coerced value as expireAfterSeconds_new. +COLLMOD_INDEX_EXPIRE_COERCION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_coerce_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(expected_new)), + }, + msg=f"collMod should accept a {tid} expireAfterSeconds and coerce it to {expected_new}", + ) + for tid, val, expected_new in [ + ("int32", 50, 50), + ("int64", Int64(50), 50), + ("double_exact", 50.0, 50), + ("double_truncates_toward_zero", 50.7, 50), + ("decimal128_rounds_down", Decimal128("50.4"), 50), + ("decimal128_half_to_even_down", Decimal128("50.5"), 50), + ("decimal128_rounds_up", Decimal128("50.7"), 51), + ("decimal128_half_to_even_up", Decimal128("51.5"), 52), + ] +] + +# Property [Index expireAfterSeconds Boundaries]: the lower bound and the upper +# bound are accepted, and a negative value that truncates toward zero to the +# lower bound is accepted. +COLLMOD_INDEX_EXPIRE_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_boundary_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(expected_new)), + }, + msg=f"collMod should accept a {tid} expireAfterSeconds as {expected_new}", + ) + for tid, val, expected_new in [ + ("zero", 0, 0), + ("int32_max", INT32_MAX, INT32_MAX), + ("negative_fraction_truncates_to_zero", -0.9, 0), + ] +] + +# Property [Index expireAfterSeconds Clamp]: a value exceeding int32 max +# (an int64 above int32 max, a double or decimal128 overflow, and positive +# Infinity) is silently clamped to int32 max with no overflow error. +COLLMOD_INDEX_EXPIRE_CLAMP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_clamp_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(INT32_MAX)), + }, + msg=f"collMod should clamp a {tid} expireAfterSeconds to 2147483647", + ) + for tid, val in [ + ("int64_above_int32_max", Int64(INT32_OVERFLOW)), + ("double_above_int32_max", float(INT32_OVERFLOW)), + ("decimal128_overflow", DECIMAL128_INT64_OVERFLOW), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ] +] + +# Property [Index expireAfterSeconds NaN]: a NaN value (float or decimal128) is +# coerced to 0. +COLLMOD_INDEX_EXPIRE_NAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_nan_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(INT64_ZERO), + }, + msg=f"collMod should coerce a {tid} NaN expireAfterSeconds to 0", + ) + for tid, val in [ + ("float", FLOAT_NAN), + ("decimal128", DECIMAL128_NAN), + ] +] + +# Property [Index expireAfterSeconds Same Value]: setting expireAfterSeconds to +# the same value on an existing TTL index echoes both expireAfterSeconds_old and +# expireAfterSeconds_new. +COLLMOD_INDEX_EXPIRE_SAME_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_same_value", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": 100}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(100)), + }, + msg="collMod should echo both old and new TTL when the value is unchanged", + ), +] + +# Property [Index expireAfterSeconds Converts Non-TTL]: setting +# expireAfterSeconds on a single-field non-TTL index converts it to a TTL index, +# echoing expireAfterSeconds_new with no expireAfterSeconds_old. +COLLMOD_INDEX_EXPIRE_CONVERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_converts_non_ttl", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "expireAfterSeconds": 50}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": NotExists(), + "expireAfterSeconds_new": Eq(Int64(50)), + }, + msg="collMod should convert a non-TTL index to TTL with new value and no old value", + ), +] + +# Property [Index Field Combination expireAfterSeconds And hidden]: setting +# expireAfterSeconds and hidden in one index document applies both, echoing the +# new TTL as expireAfterSeconds_new and the hidden change as hidden_old and +# hidden_new. +COLLMOD_INDEX_COMBO_EXPIRE_HIDDEN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "combo_expire_and_hidden", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100, hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": 200, "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(200)), + "hidden_old": Eq(False), + "hidden_new": Eq(True), + }, + msg="collMod should apply both expireAfterSeconds and hidden in one index document", + ), +] + +# Property [Index expireAfterSeconds Non-Numeric Rejection]: a bool value and +# every other non-numeric type for index.expireAfterSeconds produce a +# type-mismatch error (bool is not treated as numeric here). +COLLMOD_INDEX_EXPIRE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} expireAfterSeconds as a type mismatch", + ) + for tid, val in [ + ("string", "100"), + ("bool_true", True), + ("bool_false", False), + ("array", [100]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index expireAfterSeconds Null Rejection]: a null +# index.expireAfterSeconds leaves no modification field and is rejected as an +# invalid option. +COLLMOD_INDEX_EXPIRE_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_null", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a null expireAfterSeconds as leaving no modification field", + ), +] + +# Property [Index expireAfterSeconds Negative Rejection]: a value that truncates +# toward zero to a negative number is rejected as an invalid option, including +# -Infinity, which is rejected as below zero rather than clamped like +Infinity. +COLLMOD_INDEX_EXPIRE_NEGATIVE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_negative_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a {tid} expireAfterSeconds that truncates to a negative value", + ) + for tid, val in [ + ("int32", -1), + ("int64", Int64(-1)), + ("double", -1.5), + ("decimal128", Decimal128("-1")), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index expireAfterSeconds Converts Special Single-Field Index Types]: +# setting expireAfterSeconds on a single-field hashed or geospatial (2dsphere) +# index converts it to a TTL index, echoing expireAfterSeconds_new with no +# expireAfterSeconds_old, because TTL eligibility is decided by the key shape +# (single-field non-_id), not the index sub-type. +COLLMOD_INDEX_EXPIRE_SPECIAL_TYPE_CONVERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_converts_{tid}", + indexes=[IndexModel(key, name=name)], + docs=[], + command=lambda ctx, n=name: { + "collMod": ctx.collection, + "index": {"name": n, "expireAfterSeconds": 100}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": NotExists(), + "expireAfterSeconds_new": Eq(Int64(100)), + }, + msg=f"collMod should convert a single-field {tid} index to TTL with a new value " + "and no old value", + ) + for tid, key, name in [ + ("hashed", [("a", "hashed")], "a_hashed"), + ("2dsphere", [("loc", "2dsphere")], "loc_2dsphere"), + ] +] + +# Property [Index expireAfterSeconds Non-Single-Field Rejection]: applying +# expireAfterSeconds to certain index types is rejected as an invalid option, +# since TTL is supported only on single-field non-_id indexes. +COLLMOD_INDEX_EXPIRE_NON_SINGLE_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_compound_index", + indexes=[IndexModel([("a", 1), ("b", 1)], name="ab_1")], + docs=[{"_id": 1, "a": 1, "b": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "ab_1", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on a compound index as an invalid option", + ), + CommandTestCase( + "expire_id_index", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "_id_", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on the _id_ index as an invalid option", + ), + CommandTestCase( + "expire_text_index", + indexes=[IndexModel([("a", "text")], name="a_text")], + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_text", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on a text index as an invalid option", + ), +] + +# Property [Index expireAfterSeconds Wildcard Crash]: applying expireAfterSeconds +# to a wildcard index must not crash the engine and must return a clean +# InvalidOptions error, since a wildcard index is not a valid TTL target. +COLLMOD_INDEX_EXPIRE_WILDCARD_CRASH_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_wildcard_index", + indexes=[IndexModel([("$**", 1)], name="wild")], + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "wild", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on a wildcard index with a clean error " + "and not crash", + marks=( + pytest.mark.engine_xcrash( + engine="mongodb", + reason="Server crashes when expireAfterSeconds is applied to a wildcard index", + ), + ), + ), +] + +COLLMOD_INDEX_EXPIRE_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_EXPIRE_COERCION_TESTS + + COLLMOD_INDEX_EXPIRE_BOUNDARY_TESTS + + COLLMOD_INDEX_EXPIRE_CLAMP_TESTS + + COLLMOD_INDEX_EXPIRE_NAN_TESTS + + COLLMOD_INDEX_EXPIRE_SAME_VALUE_TESTS + + COLLMOD_INDEX_EXPIRE_CONVERT_TESTS + + COLLMOD_INDEX_EXPIRE_SPECIAL_TYPE_CONVERT_TESTS + + COLLMOD_INDEX_COMBO_EXPIRE_HIDDEN_TESTS + + COLLMOD_INDEX_EXPIRE_TYPE_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_NULL_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_NEGATIVE_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_NON_SINGLE_FIELD_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_WILDCARD_CRASH_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_EXPIRE_TESTS)) +def test_collMod_index_expire(database_client, collection, test): + """Test collMod index expireAfterSeconds acceptance and rejection.""" + 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/collMod/test_collMod_index_hidden.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py new file mode 100644 index 000000000..e212f5430 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py @@ -0,0 +1,274 @@ +"""Tests for collMod index hidden.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INDEX_NOT_FOUND_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, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Index hidden Truthy Coercion]: a bool true or any numeric type that +# coerces to true (any nonzero value, including negatives, NaN, and Infinity) +# sets hidden on a previously unhidden index, echoing hidden_old false and +# hidden_new true, despite the docs describing the field as boolean-only. +COLLMOD_INDEX_HIDDEN_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"hidden_truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": v}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg=f"collMod should coerce a {tid} hidden value to true and echo the change", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index hidden Falsy Coercion]: any numeric zero (including negative +# zero) coerces to false, so applying it to an already unhidden index does not +# change the state and the result omits hidden_old and hidden_new. +COLLMOD_INDEX_HIDDEN_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"hidden_falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": v}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + }, + msg=f"collMod should coerce a {tid} hidden value to false, leaving the state unchanged", + ) + for tid, val in [ + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + ] +] + +# Property [Index hidden Unchanged State]: setting hidden to its current value +# (hiding an already-hidden index or unhiding an already-unhidden index) does +# not change the state, so the result omits hidden_old and hidden_new, while a +# genuine state change echoes both. +COLLMOD_INDEX_HIDDEN_UNCHANGED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hidden_already_hidden", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + }, + msg="collMod should omit hidden_old and hidden_new when hiding an already-hidden index", + ), + CommandTestCase( + "hidden_already_unhidden", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": False}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + }, + msg="collMod should omit hidden_old and hidden_new when unhiding an already-unhidden " + "index", + ), + CommandTestCase( + "hidden_unhide_changes", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": False}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(True), "hidden_new": Eq(False)}, + msg="collMod should echo the change when unhiding a hidden index", + ), +] + +# Property [Index Id Index Hidden Rejection]: identifying the _id_ index by name +# or keyPattern for a hidden change is rejected as a bad value. +COLLMOD_INDEX_ID_HIDDEN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "id_index_hidden_by_name", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "_id_", "hidden": True}, + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject hiding the _id_ index identified by name as a bad value", + ), + CommandTestCase( + "id_index_hidden_by_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"_id": 1}, "hidden": True}, + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject hiding the _id_ index identified by keyPattern as a bad value", + ), +] + +# Property [Index hidden Non-Bool-Non-Numeric Rejection]: a type outside bool +# and the numeric types for index.hidden produces a type-mismatch error. +COLLMOD_INDEX_HIDDEN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"hidden_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} hidden value as a type mismatch", + ) + for tid, val in [ + ("string", "true"), + ("array", [True]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index hidden Null Rejection]: a null index.hidden is treated as +# absent, leaving no modification field, and is rejected as an invalid option. +COLLMOD_INDEX_HIDDEN_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hidden_null", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null hidden value as absent and reject it as an invalid option", + ), +] + +# Property [Index hidden Text Index Identification]: a text index can be hidden +# by name but not by keyPattern, because a text index stores a different stored +# key pattern, so identifying it by keyPattern is index-not-found. +COLLMOD_INDEX_HIDDEN_TEXT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hidden_text_index_by_key_pattern", + indexes=[IndexModel([("a", "text")], name="a_text")], + docs=[{"_id": 1, "a": "hello world"}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"a": "text"}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject hiding a text index by keyPattern as index-not-found", + ), +] + +COLLMOD_INDEX_HIDDEN_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_HIDDEN_TRUTHY_TESTS + + COLLMOD_INDEX_HIDDEN_FALSY_TESTS + + COLLMOD_INDEX_HIDDEN_UNCHANGED_TESTS + + COLLMOD_INDEX_ID_HIDDEN_ERROR_TESTS + + COLLMOD_INDEX_HIDDEN_TYPE_ERROR_TESTS + + COLLMOD_INDEX_HIDDEN_NULL_ERROR_TESTS + + COLLMOD_INDEX_HIDDEN_TEXT_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_HIDDEN_TESTS)) +def test_collMod_index_hidden(database_client, collection, test): + """Test collMod index hidden acceptance and rejection.""" + 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/collMod/test_collMod_index_identifier.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py new file mode 100644 index 000000000..f1c9abb11 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py @@ -0,0 +1,321 @@ +"""Tests for collMod index identifier resolution by name and keyPattern.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INDEX_NOT_FOUND_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 [Index Identifier Resolution]: an index identifier given by name or +# by key pattern resolves to the matching existing index, so a paired +# modification applies and its old/new values are echoed in the result. +COLLMOD_INDEX_RESOLUTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "resolution_by_name", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": 200}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(200)), + }, + msg="collMod should resolve an index by its name", + ), + CommandTestCase( + "resolution_by_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"a": 1}, "hidden": True}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should resolve an index by its key pattern", + ), +] + +# Property [Index Identifier Ambiguity Rejection]: supplying both name and +# keyPattern, or neither, fails to unambiguously identify an index and is +# rejected as an invalid option, even when both refer to the same index. +COLLMOD_INDEX_IDENTIFIER_AMBIGUITY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "identifier_both_name_and_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "keyPattern": {"a": 1}, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject specifying both name and keyPattern as an invalid option", + ), + CommandTestCase( + "identifier_neither_name_nor_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": {"hidden": True}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject specifying neither name nor keyPattern as an invalid option", + ), +] + +# Property [Index Identifier No-Match Rejection]: a name or keyPattern that +# matches no existing index is rejected as index-not-found, including an empty +# keyPattern and a text-index keyPattern. +COLLMOD_INDEX_IDENTIFIER_NO_MATCH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_match_name", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "nonexistent", "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject a name that matches no existing index as index-not-found", + ), + CommandTestCase( + "no_match_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"b": 1}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject a keyPattern that matches no existing index as index-not-found", + ), + CommandTestCase( + "no_match_empty_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject an empty keyPattern as index-not-found", + ), + CommandTestCase( + "no_match_text_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"a": "text"}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject a text-index keyPattern as index-not-found", + ), +] + +# Property [Index Name Null Rejection]: a null index.name is treated as absent, +# so no identifier is supplied and the command is rejected as an invalid option. +COLLMOD_INDEX_NAME_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_null", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": None, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null index name as absent and reject it as an invalid option", + ), +] + +# Property [Index Name Non-String Rejection]: a non-string, non-null index.name +# produces a type-mismatch error. +COLLMOD_INDEX_NAME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"name_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": v, "hidden": True}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} index name as a type mismatch", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", ["a_1"]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index Name Literal Lookup]: an index.name string is always a literal +# lookup key, never a field path or variable, so any string content that names +# no existing index is rejected as index-not-found regardless of length. +COLLMOD_INDEX_NAME_LITERAL_ERROR_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"name_literal_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": v, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg=f"collMod should treat a {tid} index name as a literal lookup key", + ) + for tid, val in [ + ("empty", ""), + ("space", " "), + ("tab", "\t"), + ("newline", "\n"), + ("cr", "\r"), + ("nbsp", "\u00a0"), # U+00A0 no-break space. + ("name_with_space", "a b"), + ("unicode_2byte", "caf\u00e9"), # U+00E9 with accent. + ("unicode_3byte", "\u4e2d"), # U+4E2D CJK character. + ("unicode_4byte", "\U0001f600"), # U+1F600 emoji. + ("zwsp", "\u200b"), # U+200B zero-width space. + ("bom", "\ufeff"), # U+FEFF byte order mark. + ("dollar", "$"), + ("double_dollar", "$$"), + ("dotted", "a.b.c"), + ("control_low", "\x01"), # U+0001 control char. + ("control_high", "\x1f"), # U+001F control char. + ] + ], + *[ + CommandTestCase( + f"name_literal_large_{size}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, n=size: { + "collMod": ctx.collection, + "index": {"name": "x" * n, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should treat a large index name as a literal lookup key " + "with no length limit", + ) + for size in [16_777_215, 16_777_216, 16_777_217] + ], +] + +# Property [Index Key Pattern Null Rejection]: a null index.keyPattern is treated +# as absent, so no identifier is supplied and the command is rejected as an +# invalid option. +COLLMOD_INDEX_KEY_PATTERN_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "key_pattern_null", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": None, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null keyPattern as absent and reject it as an invalid option", + ), +] + +# Property [Index Key Pattern Non-Object Rejection]: a non-object, non-null +# index.keyPattern produces a type-mismatch error. +COLLMOD_INDEX_KEY_PATTERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"key_pattern_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"keyPattern": v, "hidden": True}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} keyPattern as a type mismatch", + ) + for tid, val in [ + ("string", "a_1"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [{"a": 1}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +COLLMOD_INDEX_IDENTIFIER_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_RESOLUTION_TESTS + + COLLMOD_INDEX_IDENTIFIER_AMBIGUITY_ERROR_TESTS + + COLLMOD_INDEX_IDENTIFIER_NO_MATCH_ERROR_TESTS + + COLLMOD_INDEX_NAME_NULL_ERROR_TESTS + + COLLMOD_INDEX_NAME_TYPE_ERROR_TESTS + + COLLMOD_INDEX_NAME_LITERAL_ERROR_TESTS + + COLLMOD_INDEX_KEY_PATTERN_NULL_ERROR_TESTS + + COLLMOD_INDEX_KEY_PATTERN_TYPE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_IDENTIFIER_TESTS)) +def test_collMod_index_identifier(database_client, collection, test): + """Test collMod index identifier resolution and rejection.""" + 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/collMod/test_collMod_index_prepare_unique.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py new file mode 100644 index 000000000..62821d94d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py @@ -0,0 +1,317 @@ +"""Tests for collMod index prepareUnique and its combination rule.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + 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, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Index prepareUnique Truthy Coercion]: a bool true or any numeric type +# that coerces to true (any nonzero value, including negatives, NaN, and +# Infinity) sets prepareUnique on an index that does not have it, echoing +# prepareUnique_old false and prepareUnique_new true, despite the docs describing +# the field as boolean-only. +COLLMOD_INDEX_PREPARE_UNIQUE_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"prepare_unique_truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": v}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": Eq(False), + "prepareUnique_new": Eq(True), + }, + msg=f"collMod should coerce a {tid} prepareUnique value to true and echo the change", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index prepareUnique Falsy Coercion]: any numeric zero (including +# negative zero) coerces to false, so applying it to an index that does not have +# prepareUnique does not change the state and the result omits prepareUnique_old +# and prepareUnique_new. +COLLMOD_INDEX_PREPARE_UNIQUE_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"prepare_unique_falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": v}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg=f"collMod should coerce a {tid} prepareUnique value to false, leaving the state " + "unchanged", + ) + for tid, val in [ + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + ] +] + +# Property [Index prepareUnique Unchanged State]: setting prepareUnique to its +# current value (re-setting an index that already has prepareUnique or clearing +# one that does not) does not change the state, so the result omits +# prepareUnique_old and prepareUnique_new, while a genuine state change echoes +# both. +COLLMOD_INDEX_PREPARE_UNIQUE_UNCHANGED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "prepare_unique_already_set", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should omit prepareUnique_old and prepareUnique_new when re-setting an " + "already-prepareUnique index", + ), + CommandTestCase( + "prepare_unique_already_unset", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": False}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should omit prepareUnique_old and prepareUnique_new when clearing an index " + "that has no prepareUnique", + ), + CommandTestCase( + "prepare_unique_clear_changes", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": False}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": Eq(True), + "prepareUnique_new": Eq(False), + }, + msg="collMod should echo the change when clearing prepareUnique on an index that has it", + ), +] + +# Property [Index Field Combination prepareUnique No-Change]: a prepareUnique +# value that does not change the index state may be combined with another +# modification field, since the cannot-be-combined rule is gated on an actual +# prepareUnique state change, so the other field applies and prepareUnique_old +# and prepareUnique_new are omitted. +COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_NO_CHANGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "combo_prepare_unique_unset_no_change_with_hidden", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": False, "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": Eq(False), + "hidden_new": Eq(True), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should allow a no-change prepareUnique combined with a hidden change", + ), + CommandTestCase( + "combo_prepare_unique_set_no_change_with_hidden", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True, hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True, "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": Eq(False), + "hidden_new": Eq(True), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should allow a no-change prepareUnique combined with a hidden change", + ), +] + +# Property [Index prepareUnique Non-Bool-Non-Numeric Rejection]: a type outside +# bool and the numeric types for index.prepareUnique produces a type-mismatch +# error. +COLLMOD_INDEX_PREPARE_UNIQUE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"prepare_unique_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} prepareUnique value as a type mismatch", + ) + for tid, val in [ + ("string", "true"), + ("array", [True]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index prepareUnique Null Rejection]: a null index.prepareUnique is +# treated as absent, leaving no modification field, and is rejected as an +# invalid option. +COLLMOD_INDEX_PREPARE_UNIQUE_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "prepare_unique_null", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null prepareUnique value as absent and reject it as an " + "invalid option", + ), +] + +# Property [Index Field Combination prepareUnique Change Rejection]: a prepareUnique +# change combined with any other index modification field is rejected as an +# invalid option, since a prepareUnique state change cannot be combined with +# another modification. +COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_CHANGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "combo_prepare_unique_change_with_hidden", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a prepareUnique change combined with a hidden change " + "as an invalid option", + ), + CommandTestCase( + "combo_prepare_unique_change_with_expire", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True, "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a prepareUnique change combined with an expireAfterSeconds " + "change as an invalid option", + ), +] + +COLLMOD_INDEX_PREPARE_UNIQUE_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_PREPARE_UNIQUE_TRUTHY_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_FALSY_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_UNCHANGED_TESTS + + COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_NO_CHANGE_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_TYPE_ERROR_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_NULL_ERROR_TESTS + + COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_CHANGE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_PREPARE_UNIQUE_TESTS)) +def test_collMod_index_prepare_unique(database_client, collection, test): + """Test collMod index prepareUnique acceptance, rejection, and combination rule.""" + 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/collMod/test_collMod_index_unique.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py new file mode 100644 index 000000000..483737710 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py @@ -0,0 +1,241 @@ +"""Tests for collMod index unique conversion.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + CANNOT_CONVERT_INDEX_TO_UNIQUE_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, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Index unique Truthy Conversion]: on an index with prepareUnique +# already committed and no duplicate entries, a bool true or any numeric type +# that coerces to true (any nonzero value, including negatives, NaN, and +# Infinity) converts the index to unique and echoes unique_new true. +COLLMOD_INDEX_UNIQUE_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unique_truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": v}, + }, + expected={"ok": Eq(1.0), "unique_new": Eq(True), "unique_old": NotExists()}, + msg=f"collMod should coerce a {tid} unique value to true and convert the index", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index unique Already Unique No-Op]: setting unique true on an index +# that is already unique is an accepted no-op that omits unique_old and +# unique_new from the result. +COLLMOD_INDEX_UNIQUE_NO_OP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_already_unique", + indexes=[IndexModel([("a", 1)], name="a_1", unique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + }, + expected={ + "ok": Eq(1.0), + "unique_old": NotExists(), + "unique_new": NotExists(), + }, + msg="collMod should accept unique true on an already-unique index as a no-op", + ), +] + +# Property [Index unique Non-Bool-Non-Numeric Rejection]: a type outside bool +# and the numeric types for index.unique produces a type-mismatch error. +COLLMOD_INDEX_UNIQUE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unique_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} unique value as a type mismatch", + ) + for tid, val in [ + ("string", "true"), + ("array", [True]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index unique Null Rejection]: a null index.unique is treated as +# absent, leaving no modification field, and is rejected as an invalid option. +COLLMOD_INDEX_UNIQUE_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_null", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null unique value as absent and reject it as an invalid " + "option", + ), +] + +# Property [Index unique True Without prepareUnique Rejection]: a truthy unique +# value on an index that has no committed prepareUnique is rejected as an invalid +# option, since the conversion requires prepareUnique to be set first. +COLLMOD_INDEX_UNIQUE_NO_PREPARE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_true_no_prepare", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy unique value without a prior prepareUnique " + "as an invalid option", + ), +] + +# Property [Index unique Falsy Rejection]: a falsy unique value (bool false or +# any numeric zero) attempts to make the index non-unique, which is unsupported, +# and is rejected as a bad value. +COLLMOD_INDEX_UNIQUE_FALSY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unique_falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {tid} falsy unique value as a bad value", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + ] +] + +# Property [Index unique Conversion With Duplicates Rejection]: the conversion +# workflow of prepareUnique then unique true on an index whose data contains +# duplicate entries cannot convert and is rejected as a cannot-convert error. +COLLMOD_INDEX_UNIQUE_CONVERT_DUP_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_convert_with_duplicates", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 1}], + setup=lambda coll: execute_command( + coll, + {"collMod": coll.name, "index": {"name": "a_1", "prepareUnique": True}}, + ), + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + }, + error_code=CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + msg="collMod should reject a unique conversion on an index with duplicate entries", + ), +] + +COLLMOD_INDEX_UNIQUE_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_UNIQUE_TRUTHY_TESTS + + COLLMOD_INDEX_UNIQUE_NO_OP_TESTS + + COLLMOD_INDEX_UNIQUE_TYPE_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_NULL_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_NO_PREPARE_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_FALSY_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_CONVERT_DUP_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_UNIQUE_TESTS)) +def test_collMod_index_unique(database_client, collection, test): + """Test collMod index unique conversion acceptance and rejection.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py new file mode 100644 index 000000000..58f6eba7d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py @@ -0,0 +1,417 @@ +"""Tests for collMod cross-option parameter interactions.""" + +from __future__ import annotations + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INDEX_NOT_FOUND_ERROR, + INVALID_OPTIONS_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.property_checks import Eq, NotExists +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + TimeseriesCollection, + TimeseriesTTLCollection, + ViewCollection, +) + +# Property [Cross-Group Coexistence]: independent option groups that target the +# same collection type apply together in one command, and each group's effect is +# reflected in the result independently of the others. +COLLMOD_CROSS_GROUP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "index_hidden_and_validator", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "validator": {"a": 1}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should apply an index hidden change together with a validator", + ), + CommandTestCase( + "index_hidden_and_validation_level", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "validationLevel": "moderate", + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should apply an index hidden change together with a validationLevel", + ), + CommandTestCase( + "validator_and_change_stream_pre_and_post_images", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": 1}, + "changeStreamPreAndPostImages": {"enabled": True}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a validator together with changeStreamPreAndPostImages", + ), + CommandTestCase( + "validator_and_validation_level_and_action", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": 1}, + "validationLevel": "strict", + "validationAction": "error", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a validator together with validationLevel and validationAction", + ), +] + +# Property [Clustered Index And Top-Level expireAfterSeconds]: on a clustered +# collection, an index hidden change and a top-level expireAfterSeconds both +# apply in one command, echoing the index hidden state change. +COLLMOD_CLUSTERED_INDEX_AND_EXPIRE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clustered_index_hidden_and_expire", + target_collection=ClusteredCollection(), + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "expireAfterSeconds": 100, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should apply an index hidden change and a top-level expireAfterSeconds " + "on a clustered collection", + ), +] + +# Property [Validation Options Without Existing Validator]: validationLevel and +# validationAction set together on a collection that never had a validator are +# accepted, so they do not require a pre-existing validator. +COLLMOD_VALIDATION_NO_PRIOR_VALIDATOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "validation_level_and_action_together", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validationLevel": "moderate", + "validationAction": "warn", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept validationLevel and validationAction together with no " + "pre-existing validator", + ), +] + +# Property [All-Null Across Groups]: when every cross-group option is null, the +# command is a no-op success that echoes no modification fields. +COLLMOD_ALL_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_null_across_groups", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": None, + "validationLevel": None, + "validationAction": None, + "index": None, + "changeStreamPreAndPostImages": None, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + "expireAfterSeconds_old": NotExists(), + "expireAfterSeconds_new": NotExists(), + }, + msg="collMod should treat all-null cross-group options as a no-op", + ), +] + +# Property [Time Series Cross-Group Coexistence]: a timeseries modification +# applies together with a top-level expireAfterSeconds, a comment, or a +# writeConcern on a time series collection. +COLLMOD_TIMESERIES_CROSS_GROUP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_and_top_level_expire", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + "expireAfterSeconds": 100, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a timeseries modification and a top-level expireAfterSeconds " + "on a time series collection", + ), + CommandTestCase( + "timeseries_and_comment", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + "comment": "hello", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a timeseries modification together with a comment", + ), + CommandTestCase( + "timeseries_and_write_concern", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + "writeConcern": {}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a timeseries modification together with a writeConcern", + ), +] + +# Property [Time Series Ignores Capped Options]: a timeseries modification +# combined with cappedSize or cappedMax on a time series collection is a silent +# no-op success, since the capped options are ignored rather than rejected. +COLLMOD_TIMESERIES_CAPPED_NOOP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_and_capped_size", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {}, + "cappedSize": 100_000, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should silently ignore cappedSize on a time series collection", + ), + CommandTestCase( + "timeseries_and_capped_max", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {}, + "cappedMax": 1000, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should silently ignore cappedMax on a time series collection", + ), +] + +# Property [View Cross-Group Coexistence]: view options coexist in one command, +# so a null viewOn paired with a null pipeline is a no-op success and a viewOn +# value paired with a comment is accepted. +COLLMOD_VIEW_CROSS_GROUP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_on_null_and_pipeline_null", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": None, "pipeline": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should treat a null viewOn and null pipeline as a no-op", + ), + CommandTestCase( + "view_on_and_comment", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "viewOn": "some_source", + "comment": "hello", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a viewOn together with a comment", + ), +] + +# Property [Capped Size And Validator Coexistence]: a cappedSize modification +# and a validator apply together in one command on a capped collection. +COLLMOD_CAPPED_SIZE_AND_VALIDATOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_size_and_validator", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": 100_000, + "validator": {"a": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a cappedSize and a validator together on a capped collection", + ), +] + +# Property [Validation Change Result Shape]: a successful validator, +# validationLevel, or validationAction change returns ok:1.0 and echoes no +# old/new modification fields, unlike an index hidden or expireAfterSeconds +# change which does echo them. +COLLMOD_VALIDATION_RESULT_SHAPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "result_shape_validator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + expected={ + "ok": Eq(1.0), + "validator_old": NotExists(), + "validator_new": NotExists(), + }, + msg="collMod should return ok:1.0 with no old/new echo for a validator change", + ), + CommandTestCase( + "result_shape_validation_level", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationLevel": "moderate"}, + expected={ + "ok": Eq(1.0), + "validationLevel_old": NotExists(), + "validationLevel_new": NotExists(), + }, + msg="collMod should return ok:1.0 with no old/new echo for a validationLevel change", + ), + CommandTestCase( + "result_shape_validation_action", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationAction": "warn"}, + expected={ + "ok": Eq(1.0), + "validationAction_old": NotExists(), + "validationAction_new": NotExists(), + }, + msg="collMod should return ok:1.0 with no old/new echo for a validationAction change", + ), +] + +COLLMOD_INTERACTIONS_SUCCESS_TESTS: list[CommandTestCase] = ( + COLLMOD_CROSS_GROUP_TESTS + + COLLMOD_CLUSTERED_INDEX_AND_EXPIRE_TESTS + + COLLMOD_VALIDATION_NO_PRIOR_VALIDATOR_TESTS + + COLLMOD_ALL_NULL_TESTS + + COLLMOD_TIMESERIES_CROSS_GROUP_TESTS + + COLLMOD_TIMESERIES_CAPPED_NOOP_TESTS + + COLLMOD_VIEW_CROSS_GROUP_TESTS + + COLLMOD_CAPPED_SIZE_AND_VALIDATOR_TESTS + + COLLMOD_VALIDATION_RESULT_SHAPE_TESTS +) + +# Property [Unrelated Option Does Not Suppress Index Resolution]: when an index +# modification names a nonexistent index, an unrelated option group present in +# the same command does not suppress the index lookup, so the index-not-found +# error still surfaces. +COLLMOD_INDEX_RESOLUTION_NOT_SUPPRESSED_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_size_and_index_missing", + target_collection=CappedCollection(), + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": 200_000, + "index": {"name": "nonexistent", "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should surface an index-not-found error when a missing index is combined " + "with a cappedSize", + ), +] + +# Property [View Options On A Regular Collection]: a view-only option applied to +# a regular (non-view) collection is rejected rather than converting the +# collection into a view. +COLLMOD_VIEW_OPTION_ON_REGULAR_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "regular_collection_view_on", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "viewOn": "some_source"}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a viewOn applied to a regular collection", + ), + CommandTestCase( + "regular_collection_pipeline", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "pipeline": []}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a pipeline applied to a regular collection", + ), +] + +# Property [Time Series Index Resolution]: an index modification on a time +# series collection resolves against the system.buckets namespace that backs the +# collection, so a nonexistent index produces an index-not-found error +# referencing that namespace. +COLLMOD_TIMESERIES_INDEX_RESOLUTION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_index_resolves_against_bucket_namespace", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "nonexistent", "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should resolve an index on a time series collection against the " + "bucket-backing namespace, producing an index-not-found error", + ), +] + +# Property [Top-Level Unknown Field Rejection]: an unrecognized top-level command +# field is rejected, and field-name matching is case-sensitive, so a case-variant +# of a known option is rejected too. +COLLMOD_TOP_LEVEL_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_top_level_field", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "unknownField": "hello"}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unrecognized top-level command field", + ), + CommandTestCase( + "case_variant_top_level_field", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "CappedSize": 100_000}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject a case-variant of a known top-level field as unrecognized", + ), +] + +COLLMOD_INTERACTIONS_ERROR_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_RESOLUTION_NOT_SUPPRESSED_ERROR_TESTS + + COLLMOD_VIEW_OPTION_ON_REGULAR_ERROR_TESTS + + COLLMOD_TIMESERIES_INDEX_RESOLUTION_ERROR_TESTS + + COLLMOD_TOP_LEVEL_UNKNOWN_FIELD_ERROR_TESTS +) + +COLLMOD_INTERACTIONS_TESTS: list[CommandTestCase] = ( + COLLMOD_INTERACTIONS_SUCCESS_TESTS + COLLMOD_INTERACTIONS_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INTERACTIONS_TESTS)) +def test_collMod_interactions(database_client, collection, test): + """Test collMod cross-option parameter interactions.""" + 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/collMod/test_collMod_pipeline.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py new file mode 100644 index 000000000..41ddc2d55 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py @@ -0,0 +1,412 @@ +"""Tests for collMod view pipeline.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CHANGE_STREAM_NOT_ALLOWED_ERROR, + FACET_PIPELINE_INVALID_STAGE_ERROR, + GRAPH_CONTAINS_CYCLE_ERROR, + INVALID_NAMESPACE_ERROR, + LOOKUP_SUB_PIPELINE_NOT_ALLOWED_ERROR, + OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + PIPELINE_STAGE_EXTRA_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNION_WITH_SUB_PIPELINE_NOT_ALLOWED_ERROR, + UNKNOWN_PIPELINE_STAGE_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 +from documentdb_tests.framework.target_collection import ViewCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [pipeline Success]: an array pipeline is accepted as a view +# definition, null is accepted as an omitted field, null elements are silently +# dropped, a large stage count is accepted, and a range of stages valid in a +# view definition are accepted. +COLLMOD_PIPELINE_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_array", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": []}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty pipeline array", + ), + CommandTestCase( + "single_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$match": {"a": 1}}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a pipeline with a single valid object stage", + ), + CommandTestCase( + "pipeline_null", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null pipeline as an omitted field", + ), + CommandTestCase( + "single_null_element", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [None]}, + expected={"ok": Eq(1.0)}, + msg="collMod should silently drop a lone null pipeline element", + ), + CommandTestCase( + "stage_then_null_element", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$match": {"a": 1}}, None], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should silently drop a trailing null pipeline element while keeping the stage", + ), + CommandTestCase( + "many_stages", + target_collection=ViewCollection(), + # The server caps a view pipeline's stage count below the standard 10_000 + # stress value, so 1000 stages is the largest count shown to be accepted. + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$match": {"a": 1}}] * 1000}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a pipeline with 1000 stages", + ), + CommandTestCase( + "coll_stats_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$collStats": {"storageStats": {}}}], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $collStats stage in a view definition", + ), + CommandTestCase( + "index_stats_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$indexStats": {}}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an $indexStats stage in a view definition", + ), + CommandTestCase( + "sample_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$sample": {"size": 1}}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $sample stage in a view definition", + ), + CommandTestCase( + "lookup_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [ + { + "$lookup": { + "from": "other", + "localField": "a", + "foreignField": "a", + "as": "r", + } + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $lookup stage in a view definition", + ), + CommandTestCase( + "graph_lookup_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [ + { + "$graphLookup": { + "from": "other", + "startWith": "$a", + "connectFromField": "a", + "connectToField": "a", + "as": "r", + } + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $graphLookup stage in a view definition", + ), + CommandTestCase( + "facet_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$facet": {"f": [{"$match": {"a": 1}}]}}], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $facet stage in a view definition", + ), + CommandTestCase( + "union_with_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$unionWith": "other"}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $unionWith stage in a view definition", + ), +] + +# Property [pipeline Type Rejection]: any non-array value for pipeline produces +# a TypeMismatch error. +COLLMOD_PIPELINE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"pipeline_type_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "pipeline": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} pipeline as a non-array", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [pipeline Element Type Rejection]: any non-object, non-null array +# element produces a TypeMismatch error. +COLLMOD_PIPELINE_ELEMENT_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "element_string", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": ["x"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="collMod should reject a non-object pipeline element", + ), + CommandTestCase( + "element_null_then_string", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [None, "x"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="collMod should reject a non-object element after a dropped null element", + ), +] + +# Property [pipeline Stage Shape Rejection]: a stage object that does not contain +# exactly one field produces a stage-shape error. +COLLMOD_PIPELINE_STAGE_SHAPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{}]}, + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + msg="collMod should reject an empty stage object", + ), + CommandTestCase( + "two_key_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$match": {}, "$limit": 1}], + }, + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + msg="collMod should reject a stage object with two fields", + ), +] + +# Property [pipeline Unknown Stage Rejection]: a stage whose key is not a +# recognized pipeline stage name produces an unrecognized-stage error, including +# a key with no dollar prefix. +COLLMOD_PIPELINE_UNKNOWN_STAGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$nope": {}}]}, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="collMod should reject an unknown pipeline stage name", + ), + CommandTestCase( + "dollarless_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"match": {}}]}, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="collMod should reject a pipeline stage key with no dollar prefix", + ), +] + +# Property [pipeline Prohibited Output Stage Rejection]: a $out or $merge stage +# is prohibited in a view definition, with the error code determined by its +# nesting location. +COLLMOD_PIPELINE_OUTPUT_STAGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "out_top_level", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$out": "dest"}]}, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="collMod should reject a top-level $out stage in a view definition", + ), + CommandTestCase( + "merge_top_level", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$merge": "dest"}]}, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="collMod should reject a top-level $merge stage in a view definition", + ), + CommandTestCase( + "out_in_lookup", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$lookup": {"from": "other", "as": "r", "pipeline": [{"$out": "dest"}]}}], + }, + error_code=LOOKUP_SUB_PIPELINE_NOT_ALLOWED_ERROR, + msg="collMod should reject a $out stage inside a $lookup sub-pipeline", + ), + CommandTestCase( + "out_in_facet", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$facet": {"f": [{"$out": "dest"}]}}], + }, + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="collMod should reject a $out stage inside a $facet", + ), + CommandTestCase( + "out_in_union_with", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$unionWith": {"coll": "other", "pipeline": [{"$out": "dest"}]}}], + }, + error_code=UNION_WITH_SUB_PIPELINE_NOT_ALLOWED_ERROR, + msg="collMod should reject a $out stage inside a $unionWith sub-pipeline", + ), +] + +# Property [pipeline View-Incompatible Stage Rejection]: a $changeStream stage is +# rejected as an option not supported on a view, and $documents, $currentOp, and +# $listSessions stages are rejected as invalid namespaces in a view definition. +COLLMOD_PIPELINE_VIEW_INCOMPATIBLE_STAGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "change_stream_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$changeStream": {}}]}, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="collMod should reject a $changeStream stage in a view definition", + marks=(pytest.mark.requires(change_streams=True),), + ), + CommandTestCase( + "change_stream_stage_unavailable", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$changeStream": {}}]}, + error_code=CHANGE_STREAM_NOT_ALLOWED_ERROR, + msg="collMod should reject a $changeStream stage where change streams are unavailable", + marks=(pytest.mark.requires(change_streams=False),), + ), + CommandTestCase( + "documents_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$documents": []}]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a $documents stage in a view definition", + ), + CommandTestCase( + "current_op_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$currentOp": {}}]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a $currentOp stage in a view definition", + ), + CommandTestCase( + "list_sessions_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$listSessions": {}}]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a $listSessions stage in a view definition", + ), +] + +# Property [pipeline Self Reference Rejection]: a $lookup or $unionWith stage +# that references the view's own name produces a GraphContainsCycle error. +COLLMOD_PIPELINE_CYCLE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lookup_self_reference", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [ + { + "$lookup": { + "from": ctx.collection, + "localField": "a", + "foreignField": "a", + "as": "r", + } + } + ], + }, + error_code=GRAPH_CONTAINS_CYCLE_ERROR, + msg="collMod should reject a $lookup self-reference as a cycle", + ), + CommandTestCase( + "union_with_self_reference", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$unionWith": ctx.collection}], + }, + error_code=GRAPH_CONTAINS_CYCLE_ERROR, + msg="collMod should reject a $unionWith self-reference as a cycle", + ), +] + +COLLMOD_PIPELINE_TESTS: list[CommandTestCase] = ( + COLLMOD_PIPELINE_SUCCESS_TESTS + + COLLMOD_PIPELINE_TYPE_ERROR_TESTS + + COLLMOD_PIPELINE_ELEMENT_TYPE_ERROR_TESTS + + COLLMOD_PIPELINE_STAGE_SHAPE_ERROR_TESTS + + COLLMOD_PIPELINE_UNKNOWN_STAGE_ERROR_TESTS + + COLLMOD_PIPELINE_OUTPUT_STAGE_ERROR_TESTS + + COLLMOD_PIPELINE_VIEW_INCOMPATIBLE_STAGE_ERROR_TESTS + + COLLMOD_PIPELINE_CYCLE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_PIPELINE_TESTS)) +def test_collMod_pipeline(database_client, collection, test): + """Test collMod view pipeline acceptance and rejection.""" + 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/collMod/test_collMod_read_concern.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py new file mode 100644 index 000000000..f4402477e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py @@ -0,0 +1,201 @@ +"""Tests for the collMod readConcern option.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_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.property_checks import Eq +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [readConcern Acceptance]: readConcern is accepted when it is an empty +# document, the supported "local" level, null (treated as omitted), or an object +# whose level sub-field is null (treated as absent), without changing the +# command result. +COLLMOD_READ_CONCERN_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty readConcern document", + ), + CommandTestCase( + "level_local", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {"level": "local"}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a readConcern level of local", + ), + CommandTestCase( + "null", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null readConcern as an omitted field", + ), + CommandTestCase( + "level_null", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {"level": None}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null readConcern level as an absent sub-field", + ), +] + +# Property [readConcern Unsupported Level Rejection]: a recognized read concern +# level that collMod does not support (majority, available, snapshot, +# linearizable) produces an InvalidOptions error. +COLLMOD_READ_CONCERN_UNSUPPORTED_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unsupported_{level}", + docs=[], + command=lambda ctx, v=level: {"collMod": ctx.collection, "readConcern": {"level": v}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject the {level} readConcern level as unsupported", + ) + for level in ["majority", "available", "snapshot", "linearizable"] +] + +# Property [readConcern Invalid Level Enum Rejection]: the level enum is +# case-sensitive and applies no whitespace trimming, so any string that is not a +# recognized level produces a BadValue error. +COLLMOD_READ_CONCERN_INVALID_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"invalid_level_{tid}", + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "readConcern": {"level": v}}, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a readConcern level enum value", + ) + for tid, val in [ + ("empty", ""), + ("arbitrary", "bogus"), + ("capitalized", "Local"), + ("uppercase", "LOCAL"), + ("trailing_space", "local "), + ] +] + +# Property [readConcern.level Type Rejection]: a level sub-field value that is +# neither a string nor null produces a TypeMismatch error. +COLLMOD_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_type_{tid}", + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "readConcern": {"level": v}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} readConcern level as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["local"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Top-Level Type Rejection]: a readConcern value that is +# neither an object nor null produces a TypeMismatch error. +COLLMOD_READ_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "readConcern": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} readConcern as a non-object", + ) + for tid, val in [ + ("string", "local"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [{"level": "local"}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Unknown Sub-Field Rejection]: an unrecognized +# readConcern sub-field produces an UnknownField error, and that error fires +# even when the supported level sub-field is also present. +COLLMOD_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field_only", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {"bogus": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown readConcern sub-field", + ), + CommandTestCase( + "unknown_field_with_level", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "readConcern": {"level": "local", "bogus": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown readConcern sub-field even with a level present", + ), +] + +COLLMOD_READ_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_READ_CONCERN_SUCCESS_TESTS + + COLLMOD_READ_CONCERN_UNSUPPORTED_LEVEL_ERROR_TESTS + + COLLMOD_READ_CONCERN_INVALID_LEVEL_ERROR_TESTS + + COLLMOD_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS + + COLLMOD_READ_CONCERN_TYPE_ERROR_TESTS + + COLLMOD_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_READ_CONCERN_ALL_TESTS)) +def test_collMod_read_concern(database_client, collection, test): + """Test collMod readConcern option 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/collMod/test_collMod_target_name.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py new file mode 100644 index 000000000..6138c9a27 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py @@ -0,0 +1,151 @@ +"""Tests for collMod target name (the collMod command value) acceptance.""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + 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.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + TimeseriesCollection, + ViewCollection, +) + +# Property [Target Name Resolution]: a string naming an existing collection or +# view in the current database resolves successfully, and with no option field +# present the command is a no-op success regardless of the target's type. +COLLMOD_TARGET_NAME_RESOLUTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "plain_collection", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a plain collection name as a no-op success", + ), + CommandTestCase( + "capped_collection", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a capped collection name as a no-op success", + ), + CommandTestCase( + "view", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a view name as a no-op success", + ), + CommandTestCase( + "timeseries_collection", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a time series collection name as a no-op success", + ), + CommandTestCase( + "clustered_collection", + target_collection=ClusteredCollection(), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a clustered collection name as a no-op success", + ), +] + +# Property [Target Name Validation Wiring]: a structurally invalid name and a +# valid name that matches no collection each produce the expected namespace +# error. +COLLMOD_TARGET_NAME_WIRING_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "invalid_namespace_empty_string", + command={"collMod": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a structurally invalid namespace as an invalid namespace", + ), + CommandTestCase( + "not_found_nonexistent", + docs=None, + command=lambda ctx: {"collMod": ctx.collection}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="collMod should reject a valid name that matches no collection as not found", + ), +] + +# Property [Non-String Target Type Errors]: any non-string type for the target +# (including null and any array shape) produces an invalid namespace error. +COLLMOD_TARGET_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + command={"collMod": val}, + error_code=INVALID_NAMESPACE_ERROR, + msg=f"collMod should reject a {tid} target as an invalid namespace type", + ) + for tid, val in [ + ("int32", 123), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array_empty", []), + ("array_string", ["target"]), + ] +] + +COLLMOD_TARGET_NAME_TESTS: list[CommandTestCase] = ( + COLLMOD_TARGET_NAME_RESOLUTION_TESTS + + COLLMOD_TARGET_NAME_WIRING_ERROR_TESTS + + COLLMOD_TARGET_TYPE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TARGET_NAME_TESTS)) +def test_collMod_target_name(database_client, collection, test): + """Test collMod target name resolution, no-op success, and validation wiring.""" + 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/collMod/test_collMod_time_series_bucketing.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py new file mode 100644 index 000000000..54280422b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py @@ -0,0 +1,369 @@ +"""Tests for collMod time series bucketing (bucketRoundingSeconds / bucketMaxSpanSeconds).""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + 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 +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + TimeseriesCustomBucketCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + INT64_MAX, +) + +# Property [Bucketing Numeric Type Coercion]: the coupled `bucketRoundingSeconds` +# and `bucketMaxSpanSeconds` accept any numeric type. +COLLMOD_TS_BUCKET_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "numeric_int32", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 200, "bucketMaxSpanSeconds": 200}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept int32 bucketing seconds", + ), + CommandTestCase( + "numeric_int64", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": Int64(200), + "bucketMaxSpanSeconds": Int64(200), + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept int64 bucketing seconds", + ), + CommandTestCase( + "numeric_double", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 200.0, "bucketMaxSpanSeconds": 200.0}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept double bucketing seconds", + ), + CommandTestCase( + "numeric_decimal", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": Decimal128("200"), + "bucketMaxSpanSeconds": Decimal128("200"), + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept decimal128 bucketing seconds", + ), +] + +# Property [Bucketing Equality After Truncation]: the two coupled fields must be +# equal, and equality is checked after truncation toward zero, so distinct +# fractional inputs that truncate to the same integer are accepted as equal. +COLLMOD_TS_BUCKET_EQUALITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "equal_after_truncation", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": 200.9, + "bucketMaxSpanSeconds": 200.1, + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept bucketing seconds that are equal only after truncation", + ), +] + +# Property [Bucketing Range Boundaries]: the lower and upper boundary values +# are both accepted (the range is inclusive at both ends). +COLLMOD_TS_BUCKET_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lower_boundary_1", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=1), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 1, "bucketMaxSpanSeconds": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the lower boundary bucketing seconds of 1", + ), + CommandTestCase( + "upper_boundary_31536000", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": 31_536_000, + "bucketMaxSpanSeconds": 31_536_000, + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the upper boundary bucketing seconds of 31536000", + ), +] + +# Property [Bucketing Increase-Only Inclusive]: a new bucketing value greater +# than or equal to the existing/implied value is accepted, including a new value +# equal to the value implied by the seconds granularity. +COLLMOD_TS_BUCKET_INCREASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "equal_implied_3600", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 3600, "bucketMaxSpanSeconds": 3600}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a new bucketing value equal to the implied 3600", + ), +] + +# Property [Bucketing Null No-Op]: a null value for both coupled fields is an +# accepted no-op. +COLLMOD_TS_BUCKET_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_both", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": None, "bucketMaxSpanSeconds": None}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept null bucketing seconds as a no-op", + ), +] + +# Property [Bucketing Type Rejection]: a non-numeric bucketing value is rejected +# with a TypeMismatch error, and an array is not unwrapped into a numeric value. +COLLMOD_TS_BUCKET_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bucket_type_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": v, "bucketMaxSpanSeconds": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} bucketing value as the wrong type", + ) + for tid, val in [ + ("string", "x"), + ("bool_true", True), + ("bool_false", False), + ("array", [1]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Bucketing Below-Minimum Rejection]: a bucketing value below the +# minimum after truncation toward zero is rejected with a BadValue error, +# including values that coerce to zero (a fraction truncates, NaN coerces). +COLLMOD_TS_BUCKET_BELOW_MIN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bucket_below_min_{nid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": v, "bucketMaxSpanSeconds": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {nid} bucketing value as below the minimum of 1", + ) + for nid, val in [ + ("zero", 0), + ("negative", -1), + ("half", 0.5), + ("nan_float", FLOAT_NAN), + ("nan_decimal", DECIMAL128_NAN), + ] +] + +# Property [Bucketing Above-Maximum Rejection]: a bucketing value above the +# maximum after coercion is rejected with a BadValue error, including infinite and +# int64-max inputs that clamp to int32 max and still exceed the ceiling. +COLLMOD_TS_BUCKET_ABOVE_MAX_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bucket_above_max_{nid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": v, "bucketMaxSpanSeconds": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {nid} bucketing value as above the maximum of 31536000", + ) + for nid, val in [ + ("just_above", 31_536_001), + ("float_infinity", FLOAT_INFINITY), + ("decimal_infinity", DECIMAL128_INFINITY), + ("int64_max", INT64_MAX), + ] +] + +# Property [Bucketing Coupling Rejection]: setting only one of the two coupled +# fields, or setting them to values that are unequal after truncation, is +# rejected with an InvalidOptions error. +COLLMOD_TS_BUCKET_COUPLING_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "only_max_span", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketMaxSpanSeconds": 3600}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject setting only bucketMaxSpanSeconds without bucketRoundingSeconds", + ), + CommandTestCase( + "only_rounding", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 3600}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject setting only bucketRoundingSeconds without bucketMaxSpanSeconds", + ), + CommandTestCase( + "unequal_after_truncation", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": 3600.9, + "bucketMaxSpanSeconds": 3601.1, + }, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject coupled bucketing values that are unequal after truncation", + ), +] + +# Property [Bucketing Decrease Rejection]: a new bucketing value less than the +# existing/implied bucketMaxSpanSeconds is rejected with an InvalidOptions +# error, with the value implied by the seconds granularity as the threshold. +COLLMOD_TS_BUCKET_DECREASE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "below_implied_3600", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 3599, "bucketMaxSpanSeconds": 3599}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a new bucketing value below the implied 3600", + ), +] + +# Property [Bucketing With Granularity Rejection]: combining granularity with a +# custom bucketing value that differs from the granularity's default in the same +# timeseries document is rejected with an InvalidOptions error. +COLLMOD_TS_BUCKET_WITH_GRANULARITY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "granularity_and_both", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "granularity": "seconds", + "bucketRoundingSeconds": 7200, + "bucketMaxSpanSeconds": 7200, + }, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject granularity combined with non-default bucketing fields", + ), +] + +COLLMOD_TS_BUCKETING_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_BUCKET_NUMERIC_TESTS + + COLLMOD_TS_BUCKET_EQUALITY_TESTS + + COLLMOD_TS_BUCKET_BOUNDARY_TESTS + + COLLMOD_TS_BUCKET_INCREASE_TESTS + + COLLMOD_TS_BUCKET_NULL_TESTS + + COLLMOD_TS_BUCKET_TYPE_ERROR_TESTS + + COLLMOD_TS_BUCKET_BELOW_MIN_ERROR_TESTS + + COLLMOD_TS_BUCKET_ABOVE_MAX_ERROR_TESTS + + COLLMOD_TS_BUCKET_COUPLING_ERROR_TESTS + + COLLMOD_TS_BUCKET_DECREASE_ERROR_TESTS + + COLLMOD_TS_BUCKET_WITH_GRANULARITY_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_BUCKETING_TESTS)) +def test_collMod_time_series_bucketing(database_client, collection, test): + """Test collMod time series bucketing acceptance and rejection.""" + 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/collMod/test_collMod_time_series_document.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py new file mode 100644 index 000000000..c2316e6cb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py @@ -0,0 +1,171 @@ +"""Tests for collMod time series document acceptance and rejection.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_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.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + TimeseriesCollection, + ViewCollection, +) + +# Property [Timeseries Document Acceptance]: on a time series collection the +# `timeseries` document is accepted, with the empty document and null both +# treated as no-ops. +COLLMOD_TS_DOC_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty timeseries document as a no-op", + ), + CommandTestCase( + "null_document", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null timeseries document as a no-op", + ), +] + +# Property [Timeseries Type Rejection]: a non-object timeseries value is +# rejected with a TypeMismatch error, and an array is not unwrapped into a +# document. +COLLMOD_TS_DOC_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"doc_type_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "timeseries": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} timeseries value as the wrong type", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [{"granularity": "seconds"}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Timeseries Unknown Sub-Field Rejection]: an unrecognized sub-field +# inside the timeseries document is rejected, including the creation-only +# timeField and metaField sub-fields that are not modifiable through collMod. +COLLMOD_TS_DOC_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unknown_{fid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, sub=subdoc: {"collMod": ctx.collection, "timeseries": sub}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg=f"collMod should reject an unknown {fid} sub-field in the timeseries document", + ) + for fid, subdoc in [ + ("arbitrary", {"bogus": 1}), + ("time_field", {"timeField": "ts"}), + ("meta_field", {"metaField": "meta"}), + ] +] + +# Property [Timeseries Unsupported Target Rejection]: applying the timeseries +# document to a non-time-series collection (regular, capped, clustered, or view) +# is rejected because the option is only supported on time series collections. +COLLMOD_TS_DOC_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "doc_target_regular", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a regular collection", + ), + CommandTestCase( + "target_capped", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a capped collection", + ), + CommandTestCase( + "target_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a clustered collection", + ), + CommandTestCase( + "doc_target_view", + target_collection=ViewCollection(options={"pipeline": []}), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a view", + ), +] + +COLLMOD_TS_DOCUMENT_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_DOC_SUCCESS_TESTS + + COLLMOD_TS_DOC_TYPE_ERROR_TESTS + + COLLMOD_TS_DOC_UNKNOWN_FIELD_ERROR_TESTS + + COLLMOD_TS_DOC_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_DOCUMENT_TESTS)) +def test_collMod_time_series_document(database_client, collection, test): + """Test collMod time series document acceptance and rejection.""" + 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/collMod/test_collMod_time_series_expire.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py new file mode 100644 index 000000000..a2f3f294f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py @@ -0,0 +1,371 @@ +"""Tests for collMod top-level expireAfterSeconds on time series collections.""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + 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 +from documentdb_tests.framework.target_collection import ( + ClusteredCollection, + TimeseriesTTLCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# The time series TTL ceiling is the current epoch seconds: a value at or below +# now is accepted, a value above it is rejected. The tests offset from now by +# this margin in each direction so they do not assume the test runner and server +# clocks match to the second. It is generous enough to absorb realistic clock +# skew plus command latency, while still bracketing the ceiling close enough to +# now that a fixed-constant ceiling could not stay inside the window across runs. +_EPOCH_MARGIN_SECONDS = 60 + +# Property [Clear TTL]: the exact lowercase string "off" clears the TTL and is +# accepted on both time series and clustered collections. +COLLMOD_TS_EXPIRE_OFF_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "off_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": "off"}, + expected={"ok": Eq(1.0)}, + msg="collMod should clear the TTL with 'off' on a time series collection", + ), + CommandTestCase( + "off_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": "off"}, + expected={"ok": Eq(1.0)}, + msg="collMod should clear the TTL with 'off' on a clustered collection", + ), +] + +# Property [Numeric Type Acceptance]: any numeric type is accepted despite the +# declared [string, long] type, with the value set as the TTL on both time +# series and clustered collections. +COLLMOD_TS_EXPIRE_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "numeric_int32_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_int64_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": Int64(100)}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int64 expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_double_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100.0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a double expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_decimal_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": Decimal128("100"), + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a decimal128 expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_int32_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 expireAfterSeconds on a clustered collection", + ), +] + +# Property [Positive Fractional Acceptance]: a positive fractional value is +# accepted (the command response does not echo the coerced/stored value). +COLLMOD_TS_EXPIRE_POSITIVE_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "positive_fractional_double", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100.9}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a positive fractional expireAfterSeconds", + ), + CommandTestCase( + "positive_fractional_decimal", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": Decimal128("100.9"), + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a positive fractional decimal128 expireAfterSeconds", + ), +] + +# Property [Negative-to-Zero Acceptance]: a negative value that truncates to 0 +# is accepted, exercising the near-boundary partner to the error property where +# a value truncating to <= -1 is rejected. +COLLMOD_TS_EXPIRE_NEGATIVE_ZERO_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "negative_double_near_one", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": -0.9}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a negative double expireAfterSeconds just above -1 as 0", + ), + CommandTestCase( + "negative_decimal", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": DECIMAL128_NEGATIVE_HALF, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a negative decimal expireAfterSeconds as 0", + ), +] + +# Property [NaN Coercion]: a NaN value (float or decimal) is coerced to 0 and +# accepted rather than rejected. +COLLMOD_TS_EXPIRE_NAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "nan_float", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": FLOAT_NAN}, + expected={"ok": Eq(1.0)}, + msg="collMod should coerce a float NaN expireAfterSeconds to 0", + ), + CommandTestCase( + "nan_decimal", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": DECIMAL128_NAN}, + expected={"ok": Eq(1.0)}, + msg="collMod should coerce a decimal NaN expireAfterSeconds to 0", + ), +] + +# Property [Epoch Acceptance on Time Series]: a value just below the current +# wall-clock epoch seconds is accepted on a time series collection (the accepted +# partner to the rejection of values above now). +COLLMOD_TS_EXPIRE_EPOCH_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "epoch_just_below_now", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": int(time.time()) - _EPOCH_MARGIN_SECONDS, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an expireAfterSeconds just below the current epoch seconds " + "on a time series collection", + ), +] + +# Property [Type Rejection]: a top-level expireAfterSeconds value whose type is +# outside the accepted string and numeric types produces a TypeMismatch error. +COLLMOD_TS_EXPIRE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_type_{tid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} expireAfterSeconds as the wrong type", + ) + for tid, val in [ + ("null", None), + ("bool_true", True), + ("bool_false", False), + ("array", [100]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Invalid String Rejection]: any string other than the exact lowercase +# "off" is rejected, including case variants and whitespace-padded forms. +COLLMOD_TS_EXPIRE_STRING_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"string_{sid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject the {sid} string expireAfterSeconds", + ) + for sid, val in [ + ("title_case", "Off"), + ("upper_case", "OFF"), + ("empty", ""), + ("on", "on"), + ("trailing_space", "off "), + ("leading_space", " off"), + ] +] + +# Property [Below-Zero Rejection]: a numeric value that truncates to <= -1 is +# rejected ("cannot be less than 0"), including -Infinity which is rejected as +# below zero rather than as an overflow. +COLLMOD_TS_EXPIRE_BELOW_ZERO_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"below_zero_{nid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a {nid} expireAfterSeconds as below zero", + ) + for nid, val in [ + ("int", -1), + ("double", -1.0), + ("decimal", Decimal128("-1")), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ] +] + +# Property [Overflow Rejection]: a value that overflows the int64 milliseconds +# conversion is rejected as out of int64 range. +COLLMOD_TS_EXPIRE_OVERFLOW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"overflow_{oid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a {oid} expireAfterSeconds as out of int64 range", + ) + for oid, val in [ + ("float_infinity", FLOAT_INFINITY), + ("decimal_infinity", DECIMAL128_INFINITY), + # The smallest int64 whose conversion to milliseconds (the engine's + # `* 1000` cast) overflows int64, exercising the overflow rejection + # path distinct from the simpler out-of-range checks. + ("int64_millis", Int64(9223372036854776)), + ] +] + +# Property [Epoch Ceiling on Time Series]: on a time series collection, a value +# above the current wall-clock epoch seconds is rejected (the rejected partner +# to the acceptance of values below now). +COLLMOD_TS_EXPIRE_EPOCH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "epoch_above_now", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": int(time.time()) + _EPOCH_MARGIN_SECONDS, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject an expireAfterSeconds above the current epoch seconds on a " + "time series collection", + ), +] + +# Property [Unsupported Target Rejection]: a top-level expireAfterSeconds applied +# to a regular collection or a view is rejected because the option is only +# supported on collections clustered by _id. +COLLMOD_TS_EXPIRE_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_target_regular", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a top-level expireAfterSeconds on a regular collection", + ), + CommandTestCase( + "expire_target_view", + target_collection=ViewCollection(options={"pipeline": []}), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a top-level expireAfterSeconds on a view", + ), +] + +COLLMOD_TS_EXPIRE_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_EXPIRE_OFF_TESTS + + COLLMOD_TS_EXPIRE_NUMERIC_TESTS + + COLLMOD_TS_EXPIRE_POSITIVE_FRACTIONAL_TESTS + + COLLMOD_TS_EXPIRE_NEGATIVE_ZERO_TESTS + + COLLMOD_TS_EXPIRE_NAN_TESTS + + COLLMOD_TS_EXPIRE_EPOCH_TESTS + + COLLMOD_TS_EXPIRE_TYPE_ERROR_TESTS + + COLLMOD_TS_EXPIRE_STRING_ERROR_TESTS + + COLLMOD_TS_EXPIRE_BELOW_ZERO_ERROR_TESTS + + COLLMOD_TS_EXPIRE_OVERFLOW_ERROR_TESTS + + COLLMOD_TS_EXPIRE_EPOCH_ERROR_TESTS + + COLLMOD_TS_EXPIRE_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_EXPIRE_TESTS)) +def test_collMod_time_series_expire(database_client, collection, test): + """Test collMod top-level expireAfterSeconds acceptance and rejection on time series.""" + 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/collMod/test_collMod_time_series_granularity.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py new file mode 100644 index 000000000..54f40bd7d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py @@ -0,0 +1,190 @@ +"""Tests for collMod time series granularity.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + 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 +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, +) + +# Property [Granularity Enum Acceptance]: a granularity equal to one of the +# valid enum strings ("seconds", "minutes", "hours") is accepted, and a null +# granularity is an accepted no-op. +COLLMOD_TS_GRANULARITY_ENUM_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"enum_{gid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "timeseries": {"granularity": v}}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {gid} granularity", + ) + for gid, val in [ + ("seconds", "seconds"), + ("minutes", "minutes"), + ("hours", "hours"), + ("null", None), + ] +] + +# Property [Granularity Increase Transition]: a granularity change that +# increases or holds the granularity from a non-default starting granularity is +# accepted, starting from a collection already created at the prior granularity. +COLLMOD_TS_GRANULARITY_TRANSITION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "transition_minutes_to_hours", + target_collection=TimeseriesCollection( + timeseries_options={"timeField": "ts", "metaField": "meta", "granularity": "minutes"} + ), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "hours"}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a granularity increase from minutes to hours", + ), + CommandTestCase( + "transition_hours_same_value", + target_collection=TimeseriesCollection( + timeseries_options={"timeField": "ts", "metaField": "meta", "granularity": "hours"} + ), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "hours"}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a same-value hours granularity as a no-op", + ), +] + +# Property [Granularity Type Rejection]: a non-string granularity value is +# rejected with a TypeMismatch error. +COLLMOD_TS_GRANULARITY_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"granularity_type_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"granularity": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} granularity as the wrong type", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", ["seconds"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Granularity Enum Rejection]: the granularity enum is case-sensitive +# and accepts only the exact lowercase seconds, minutes, or hours, so any other +# string (case variants, near-misses, the empty string, and an oversized +# invalid string) produces a BadValue error rather than a string-size error. +COLLMOD_TS_GRANULARITY_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"granularity_enum_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"granularity": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a granularity enum value", + ) + for tid, val in [ + ("empty", ""), + ("capitalized", "Seconds"), + ("uppercase", "SECONDS"), + ("singular", "second"), + ("arbitrary", "days"), + ("large_invalid", "x" * 16_000_000), + ] +] + +# Property [Granularity Decrease Rejection]: a granularity change that decreases +# the granularity is rejected as an invalid transition, starting from a +# collection already created at a higher granularity. +COLLMOD_TS_GRANULARITY_DECREASE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "granularity_decrease_hours_to_minutes", + target_collection=TimeseriesCollection( + timeseries_options={"timeField": "ts", "metaField": "meta", "granularity": "hours"} + ), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a granularity decrease from hours to minutes", + ), +] + +COLLMOD_TS_GRANULARITY_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_GRANULARITY_ENUM_TESTS + + COLLMOD_TS_GRANULARITY_TRANSITION_TESTS + + COLLMOD_TS_GRANULARITY_TYPE_ERROR_TESTS + + COLLMOD_TS_GRANULARITY_ENUM_ERROR_TESTS + + COLLMOD_TS_GRANULARITY_DECREASE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_GRANULARITY_TESTS)) +def test_collMod_time_series_granularity(database_client, collection, test): + """Test collMod time series granularity acceptance and rejection.""" + 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/collMod/test_collMod_validation_level_action.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py new file mode 100644 index 000000000..f44ee5c71 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py @@ -0,0 +1,254 @@ +"""Tests for collMod validationLevel and validationAction.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + 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 +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [validationLevel Success]: a validationLevel equal to off, strict, +# or moderate is accepted, and null is accepted as a no-op. +COLLMOD_VALIDATION_LEVEL_SUCCESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"level_{lvl}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=lvl: {"collMod": ctx.collection, "validationLevel": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept the {lvl} validationLevel", + ) + for lvl in ["off", "strict", "moderate"] + ], + CommandTestCase( + "level_null", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationLevel": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null validationLevel as an omitted field", + ), +] + +# Property [validationLevel Type Rejection]: a validationLevel value that is +# neither a string nor null produces a TypeMismatch error. +COLLMOD_VALIDATION_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_type_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationLevel": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} validationLevel as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["strict"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [validationLevel Enum Rejection]: the validationLevel enum is +# case-sensitive and applies no whitespace trimming, so any string other than +# the exact lowercase off, strict, or moderate produces a BadValue error, and a +# dollar-prefixed string is rejected as a literal value rather than a field path +# or variable reference. +COLLMOD_VALIDATION_LEVEL_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_enum_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationLevel": v}, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a validationLevel enum value", + ) + for tid, val in [ + ("empty", ""), + ("arbitrary", "nope"), + ("capitalized_strict", "Strict"), + ("uppercase_off", "OFF"), + ("leading_space", " strict"), + ("trailing_space", "strict "), + ("embedded_space", "str ict"), + ("nbsp", "strict\u00a0"), + ("dollar", "$"), + ("dollar_dollar", "$$"), + ("large_invalid", "x" * 16_000_000), + ] +] + +# Property [validationLevel Unsupported Collection Type Rejection]: a +# validationLevel applied to a collection type that does not support validation +# (a view or a time series collection) is rejected. +COLLMOD_VALIDATION_LEVEL_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_unsupported_{target_id}", + docs=[], + target_collection=target, + command=lambda ctx: {"collMod": ctx.collection, "validationLevel": "strict"}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validationLevel on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] +] + +# Property [validationAction Success]: a validationAction equal to error, warn, +# or errorAndLog is accepted, and null is accepted as a no-op. +COLLMOD_VALIDATION_ACTION_SUCCESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"action_{action}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=action: {"collMod": ctx.collection, "validationAction": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept the {action} validationAction", + ) + for action in ["error", "warn", "errorAndLog"] + ], + CommandTestCase( + "action_null", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationAction": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null validationAction as an omitted field", + ), +] + +# Property [validationAction Type Rejection]: a validationAction value that is +# neither a string nor null produces a TypeMismatch error. +COLLMOD_VALIDATION_ACTION_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"action_type_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationAction": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} validationAction as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["error"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [validationAction Enum Rejection]: the validationAction enum is +# case-sensitive and applies no whitespace trimming, so any string other than +# the exact lowercase error, warn, or errorAndLog produces a BadValue error, and +# a dollar-prefixed string is rejected as a literal value rather than a field +# path or variable reference. +COLLMOD_VALIDATION_ACTION_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"action_enum_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationAction": v}, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a validationAction enum value", + ) + for tid, val in [ + ("empty", ""), + ("arbitrary", "nope"), + ("capitalized_error", "Error"), + ("uppercase_warn", "WARN"), + ("leading_space", " error"), + ("trailing_space", "error "), + ("embedded_space", "err or"), + ("nbsp", "error\u00a0"), + ("dollar", "$"), + ("dollar_dollar", "$$"), + ("large_invalid", "x" * 16_000_000), + ] +] + +# Property [validationAction Unsupported Collection Type Rejection]: a +# validationAction applied to a collection type that does not support validation +# (a view or a time series collection) is rejected. +COLLMOD_VALIDATION_ACTION_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"action_unsupported_{target_id}", + docs=[], + target_collection=target, + command=lambda ctx: {"collMod": ctx.collection, "validationAction": "error"}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validationAction on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] +] + +COLLMOD_VALIDATION_LEVEL_ACTION_TESTS: list[CommandTestCase] = ( + COLLMOD_VALIDATION_LEVEL_SUCCESS_TESTS + + COLLMOD_VALIDATION_LEVEL_TYPE_ERROR_TESTS + + COLLMOD_VALIDATION_LEVEL_ENUM_ERROR_TESTS + + COLLMOD_VALIDATION_LEVEL_UNSUPPORTED_TARGET_ERROR_TESTS + + COLLMOD_VALIDATION_ACTION_SUCCESS_TESTS + + COLLMOD_VALIDATION_ACTION_TYPE_ERROR_TESTS + + COLLMOD_VALIDATION_ACTION_ENUM_ERROR_TESTS + + COLLMOD_VALIDATION_ACTION_UNSUPPORTED_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_VALIDATION_LEVEL_ACTION_TESTS)) +def test_collMod_validation_level_action(database_client, collection, register_db_cleanup, test): + """Test collMod validationLevel and validationAction acceptance and rejection.""" + collection = test.prepare(database_client, collection) + if collection.database.name != database_client.name: + register_db_cleanup(f"{collection.database.name}.{collection.name}") + 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/collMod/test_collMod_validator.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py new file mode 100644 index 000000000..178e3aa7a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py @@ -0,0 +1,447 @@ +"""Tests for collMod validator.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_OPTIONS_ERROR, + NEAR_NOT_ALLOWED_ERROR, + REGEX_COMPILE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_EXPRESSION_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 +from documentdb_tests.framework.target_collection import ( + ExistingDatabase, + SystemViewsCollection, + TimeseriesCollection, + ValidatedCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + REGEX_PATTERN_LIMIT_BYTES, +) + +# Property [validator Success]: a validator value that is an object (including +# the empty document) or null (treated as omitted) is accepted, and any object +# expressing a well-formed match query, a valid $expr, or a valid $jsonSchema is +# accepted as a collection validator. +COLLMOD_VALIDATOR_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty document validator", + ), + CommandTestCase( + "null", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null validator as an omitted field", + ), + CommandTestCase( + "field_equality_query", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a simple field-equality query validator", + ), + CommandTestCase( + "dotted_path_query", + docs=[{"_id": 1, "a": {"b": 1}}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a.b": {"$exists": True}}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a dotted-path query validator", + ), + CommandTestCase( + "type_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": {"$type": "int"}}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $type operator in a validator", + ), + CommandTestCase( + "mod_operator", + docs=[{"_id": 1, "a": 4}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": {"$mod": [2, 0]}}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $mod operator in a validator", + ), + CommandTestCase( + "large_string_value", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": "x" * 10_000}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a validator carrying a large string value", + ), + CommandTestCase( + "expr_bare_true", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$expr": True}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a bare $expr: true validator", + ), + CommandTestCase( + "expr_comparison", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$gt": ["$a", 0]}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $expr comparison validator", + ), + CommandTestCase( + "expr_root_variable", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$eq": ["$$ROOT.a", "$a"]}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $expr validator referencing the $$ROOT variable", + ), + CommandTestCase( + "expr_now_variable", + docs=[{"_id": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$lte": ["$created", "$$NOW"]}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $expr validator referencing the $$NOW variable", + ), + CommandTestCase( + "json_schema_valid_bson_type", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"properties": {"a": {"bsonType": "int"}}}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $jsonSchema validator with a valid bsonType", + ), + CommandTestCase( + "regex_pattern_at_limit", + docs=[{"_id": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$regex": "a" * REGEX_PATTERN_LIMIT_BYTES}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a validator regex pattern at the 16384-byte limit", + ), +] + +# Property [validator No Retroactive Validation]: setting a validator does not +# validate documents already present in the collection, so the command succeeds +# (ok:1.0) even when an existing document would fail the newly-set validator, +# including under an explicit strict/error validation mode. +COLLMOD_VALIDATOR_NO_RETROACTIVE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "json_schema_existing_doc_violates", + docs=[{"_id": 1, "a": "not_an_int"}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"properties": {"a": {"bsonType": "int"}}}}, + "validationLevel": "strict", + "validationAction": "error", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $jsonSchema validator even when an existing document " + "violates it, since existing documents are not validated retroactively", + ), +] + +# Property [validator Clears Previously-Set Validator]: an empty {} validator +# applied to a collection that already has a validator is accepted (ok:1.0), +# resetting it to no validation. +COLLMOD_VALIDATOR_CLEAR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_clears_existing_validator", + target_collection=ValidatedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "validator": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty {} validator that clears a previously-set validator", + ), +] + +# Property [validator Type Rejection]: a validator value that is neither an +# object nor null produces a TypeMismatch error. +COLLMOD_VALIDATOR_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validator": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} validator as a non-object", + ) + for tid, val in [ + ("string", "not_an_object"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [{"a": 1}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [validator Query Operator Rejection]: query operators that cannot be +# used in a collection validator are rejected, with the error code determined by +# the specific operator. +COLLMOD_VALIDATOR_OPERATOR_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "where_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$where": "true"}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a $where operator in a validator", + ), + CommandTestCase( + "text_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$text": {"$search": "x"}}, + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a $text operator in a validator", + ), + CommandTestCase( + "unknown_dollar_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": {"$badOp": 1}}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an unknown query operator in a validator", + ), + CommandTestCase( + "near_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$near": [0, 0]}}, + }, + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="collMod should reject a $near operator in a validator", + ), + CommandTestCase( + "near_sphere_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$nearSphere": [0, 0]}}, + }, + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="collMod should reject a $nearSphere operator in a validator", + ), + CommandTestCase( + "geo_near_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$geoNear": [0, 0]}}, + }, + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="collMod should reject a $geoNear operator in a validator", + ), + CommandTestCase( + "expr_unknown_aggregation_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$unknownAggOp": [1, 2]}}, + }, + error_code=UNRECOGNIZED_EXPRESSION_ERROR, + msg="collMod should reject an unknown aggregation operator inside $expr in a validator", + ), +] + +# Property [validator JSON Schema Rejection]: a $jsonSchema with an invalid +# keyword, an invalid type alias, or a conflicting type specification produces a +# FailedToParse error. +COLLMOD_VALIDATOR_JSON_SCHEMA_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "json_schema_unknown_keyword", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"unknownKeyword": 1}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="collMod should reject a $jsonSchema with an unknown keyword", + ), + CommandTestCase( + "json_schema_type_integer", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"properties": {"a": {"type": "integer"}}}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="collMod should reject a $jsonSchema using the integer type alias", + ), + CommandTestCase( + "json_schema_type_and_bson_type", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": { + "$jsonSchema": {"properties": {"a": {"type": "string", "bsonType": "int"}}} + }, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="collMod should reject a $jsonSchema specifying both type and bsonType on a property", + ), +] + +# Property [validator Logical Operator Rejection]: a malformed top-level logical +# operator or an unknown dollar-prefixed top-level field produces a BadValue +# error. +COLLMOD_VALIDATOR_LOGICAL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_and", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$and": []}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an empty $and in a validator", + ), + CommandTestCase( + "empty_or", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$or": []}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an empty $or in a validator", + ), + CommandTestCase( + "non_array_or", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$or": {"a": 1}}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a non-array $or in a validator", + ), + CommandTestCase( + "unknown_dollar_field", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$nope": 1}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an unknown dollar-prefixed top-level field in a validator", + ), +] + +# Property [validator Regex Limit Rejection]: a validator regex pattern one byte +# over the inclusive size limit fails to compile. +COLLMOD_VALIDATOR_REGEX_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "regex_pattern_over_limit", + docs=[{"_id": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$regex": "a" * (REGEX_PATTERN_LIMIT_BYTES + 1)}}, + }, + error_code=REGEX_COMPILE_ERROR, + msg="collMod should reject a validator regex pattern one byte over the limit", + ), +] + +# Property [validator Restricted Namespace Rejection]: a validator applied to a +# collection in a restricted database (admin, config, local) or to a system.* +# collection is rejected, regardless of the validator's well-formedness. +COLLMOD_VALIDATOR_RESTRICTED_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"restricted_db_{dbname}", + target_collection=ExistingDatabase(db_name=dbname), + # The local database does not support retryable writes, so the + # collection is created empty rather than seeded with documents; its + # existence alone is enough to trigger the validator rejection. + docs=[] if dbname == "local" else [{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validator on a collection in the {dbname} database", + ) + for dbname in ["admin", "config", "local"] + ], + CommandTestCase( + "system_views", + target_collection=SystemViewsCollection(), + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a validator on a system.* collection", + ), +] + +# Property [validator Unsupported Collection Type Rejection]: a validator +# applied to a collection type that does not support validation (a view or a +# time series collection) is rejected. +COLLMOD_VALIDATOR_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unsupported_{target_id}", + docs=[], + target_collection=target, + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validator on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] +] + +COLLMOD_VALIDATOR_TESTS: list[CommandTestCase] = ( + COLLMOD_VALIDATOR_SUCCESS_TESTS + + COLLMOD_VALIDATOR_NO_RETROACTIVE_TESTS + + COLLMOD_VALIDATOR_CLEAR_TESTS + + COLLMOD_VALIDATOR_TYPE_ERROR_TESTS + + COLLMOD_VALIDATOR_OPERATOR_ERROR_TESTS + + COLLMOD_VALIDATOR_JSON_SCHEMA_ERROR_TESTS + + COLLMOD_VALIDATOR_LOGICAL_ERROR_TESTS + + COLLMOD_VALIDATOR_REGEX_ERROR_TESTS + + COLLMOD_VALIDATOR_RESTRICTED_NAMESPACE_ERROR_TESTS + + COLLMOD_VALIDATOR_UNSUPPORTED_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_VALIDATOR_TESTS)) +def test_collMod_validator(database_client, collection, register_db_cleanup, test): + """Test collMod validator acceptance and rejection.""" + collection = test.prepare(database_client, collection) + if collection.database.name != database_client.name: + register_db_cleanup(f"{collection.database.name}.{collection.name}") + 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/collMod/test_collMod_view_on.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py new file mode 100644 index 000000000..ebfb9cc8d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py @@ -0,0 +1,184 @@ +"""Tests for collMod viewOn.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + GRAPH_CONTAINS_CYCLE_ERROR, + INVALID_NAMESPACE_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 +from documentdb_tests.framework.target_collection import ViewCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + STRING_SIZE_LIMIT_BYTES, +) + +# Property [viewOn Success]: a string viewOn is validated as a namespace but not +# checked for target existence, so any structurally valid name is accepted and +# stored verbatim - including whitespace, control characters, Unicode, and +# interior/trailing dots or database-qualified names - null is accepted as an +# omitted field, and the value has no length limit. (Structurally invalid names +# are rejected by the viewOn Namespace Rejection property.) +COLLMOD_VIEW_ON_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "nonexistent_target", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": "no_such_collection"}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a viewOn naming a nonexistent target without validating it", + ), + CommandTestCase( + "null", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null viewOn as an omitted field", + ), + *[ + CommandTestCase( + f"content_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a viewOn with {tid} content verbatim", + ) + for tid, val in [ + ("single_space", " "), + ("nbsp", "a\u00a0b"), # U+00A0 no-break space. + ("control_char", "\x01"), # U+0001 start of heading. + ("two_byte_unicode", "caf\u00e9"), # U+00E9 latin small e with acute. + ("three_byte_unicode", "\u4e2d"), # U+4E2D CJK ideograph. + ("four_byte_unicode", "\U0001f600coll"), # U+1F600 grinning face. + ("trailing_dot", "trailing."), + ("interior_dots", "a.b.c"), + ("database_qualified", "db.coll"), + ] + ], + *[ + CommandTestCase( + f"length_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {tid} viewOn value with no length-based limit", + ) + # A viewOn value has no length limit, unlike the collMod target name. + # These three sizes bracket the 16 MB BSON document size to show none of + # them hit a length-based limit. + for tid, val in [ + ("below_16mb", "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + ("at_16mb", "a" * STRING_SIZE_LIMIT_BYTES), + ("above_16mb", "a" * (STRING_SIZE_LIMIT_BYTES + 1)), + ] + ], +] + +# Property [viewOn Type Rejection]: any non-string value for viewOn produces a +# TypeMismatch error. +COLLMOD_VIEW_ON_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} viewOn as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["src"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [viewOn Empty Rejection]: an empty string viewOn produces a BadValue +# error. +COLLMOD_VIEW_ON_EMPTY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": ""}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an empty string viewOn", + ), +] + +# Property [viewOn Namespace Rejection]: a viewOn string that is structurally +# invalid as a namespace produces an InvalidNamespace error, where a leading +# dollar is treated as name content rather than a field path. +COLLMOD_VIEW_ON_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"namespace_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + error_code=INVALID_NAMESPACE_ERROR, + msg=f"collMod should reject a {tid} viewOn as a structurally invalid namespace", + ) + for tid, val in [ + ("dollar_prefixed", "$x"), + ("embedded_null", "a\x00b"), + ("leading_dot", ".leading"), + ] +] + +# Property [viewOn Self Reference Rejection]: a viewOn equal to the view's own +# name produces a GraphContainsCycle error. +COLLMOD_VIEW_ON_CYCLE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "self_reference", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": ctx.collection}, + error_code=GRAPH_CONTAINS_CYCLE_ERROR, + msg="collMod should reject a viewOn equal to the view's own name as a cycle", + ), +] + +COLLMOD_VIEW_ON_TESTS: list[CommandTestCase] = ( + COLLMOD_VIEW_ON_SUCCESS_TESTS + + COLLMOD_VIEW_ON_TYPE_ERROR_TESTS + + COLLMOD_VIEW_ON_EMPTY_ERROR_TESTS + + COLLMOD_VIEW_ON_NAMESPACE_ERROR_TESTS + + COLLMOD_VIEW_ON_CYCLE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_VIEW_ON_TESTS)) +def test_collMod_view_on(database_client, collection, test): + """Test collMod viewOn acceptance and rejection.""" + 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/collMod/test_collMod_write_concern.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py new file mode 100644 index 000000000..845b8db7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py @@ -0,0 +1,344 @@ +"""Tests for the collMod writeConcern option.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + 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.property_checks import Eq +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [writeConcern Success]: a top-level writeConcern of null is treated +# as omitted and an empty document is accepted, both succeeding without changing +# the command result. +COLLMOD_WRITE_CONCERN_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_treated_as_omitted", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "writeConcern": None, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should treat a null writeConcern as omitted", + ), + CommandTestCase( + "empty_document", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "writeConcern": {}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty writeConcern document", + ), +] + +# Property [writeConcern.w Portable Acceptance]: a number that resolves to 0 or +# 1 (after truncation), the "majority" tag, and an object tag are accepted on +# every topology without changing the command result. +COLLMOD_WRITE_CONCERN_W_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {wid} writeConcern.w value on any topology", + ) + for wid, val in [ + ("int_zero", 0), + ("int_one", 1), + ("double_fractional", 1.5), + ("int64_one", Int64(1)), + ("decimal_one", Decimal128("1")), + ("string_majority", "majority"), + ("object", {"a": 1}), + ] +] + +# Property [writeConcern.w Quorum Acceptance On Replica Set]: a quorum write +# concern (a number above 1, an unrecognized string tag, the empty string, or +# null) is accepted on a replica set, where an unsatisfiable concern surfaces +# asynchronously as a writeConcernError so the command still returns ok:1.0. +COLLMOD_WRITE_CONCERN_W_QUORUM_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_quorum_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {wid} quorum writeConcern.w on a replica set", + marks=(pytest.mark.requires(quorum_write_concern=True),), + ) + for wid, val in [ + ("int_fifty", 50), + ("decimal_above_one", Decimal128("5")), + ("string_arbitrary", "foo"), + ("string_empty", ""), + ("null", None), + ] +] + +# Property [writeConcern.j Acceptance]: a numeric, bool, or null value is +# accepted for writeConcern.j without changing the command result. +COLLMOD_WRITE_CONCERN_J_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"j_{jid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"j": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {jid} writeConcern.j value", + ) + for jid, val in [ + ("bool_true", True), + ("bool_false", False), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal", Decimal128("1")), + ("null", None), + ] +] + +# Property [writeConcern.wtimeout Acceptance]: writeConcern.wtimeout is not +# validated in this context, so a negative number and an arbitrary string are +# both accepted without changing the command result. +COLLMOD_WRITE_CONCERN_WTIMEOUT_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"wtimeout_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"wtimeout": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {wid} writeConcern.wtimeout value", + ) + for wid, val in [ + ("negative_number", -5), + ("arbitrary_string", "foo"), + ] +] + +# Property [writeConcern.j Type Rejection]: any writeConcern.j value whose type +# is outside the accepted numeric and bool types produces a TypeMismatch error. +COLLMOD_WRITE_CONCERN_J_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"j_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"j": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} writeConcern.j as a non-numeric/bool", + ) + for tid, val in [ + ("string", "x"), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [writeConcern Type Rejection]: a non-object, non-null top-level +# writeConcern produces a TypeMismatch error. +COLLMOD_WRITE_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} writeConcern as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [writeConcern.w Range Rejection]: a numeric writeConcern.w outside +# the inclusive supported range (after truncation) produces a parse error. +COLLMOD_WRITE_CONCERN_W_RANGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_range_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"collMod should reject an out-of-range writeConcern.w of {wid}", + ) + for wid, val in [ + ("fifty_one", 51), + ("negative_one", -1), + ("decimal_above_fifty", Decimal128("123.45")), + ] +] + +# Property [writeConcern.w Type Rejection]: a writeConcern.w value that is not a +# number, string, or object produces a parse error. +COLLMOD_WRITE_CONCERN_W_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"collMod should reject a {tid} writeConcern.w as non-number/string/object", + ) + for tid, val in [ + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [writeConcern.w Quorum Rejection On Standalone]: a quorum write +# concern (a number above 1, an unrecognized string tag, the empty string, or +# null) is rejected up front on a standalone with a BadValue error, since a +# standalone can never satisfy a quorum concern. +COLLMOD_WRITE_CONCERN_W_QUORUM_STANDALONE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_quorum_reject_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {wid} quorum writeConcern.w up front on a standalone", + marks=(pytest.mark.requires(quorum_write_concern=False),), + ) + for wid, val in [ + ("int_fifty", 50), + ("decimal_above_one", Decimal128("5")), + ("string_arbitrary", "foo"), + ("string_empty", ""), + ("null", None), + ] +] + +# Property [writeConcern Unknown Field Rejection]: an unrecognized writeConcern +# sub-field, and a write concern option placed at the top level instead of +# nested under writeConcern, each produce an unknown-field error. +COLLMOD_WRITE_CONCERN_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_subfield", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "writeConcern": {"bogus": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown writeConcern sub-field", + ), + CommandTestCase( + "bare_top_level_w", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "w": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject a bare top-level w not nested under writeConcern", + ), +] + +COLLMOD_WRITE_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_WRITE_CONCERN_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_W_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_W_QUORUM_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_J_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_WTIMEOUT_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_J_TYPE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_TYPE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_W_RANGE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_W_TYPE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_W_QUORUM_STANDALONE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_WRITE_CONCERN_ALL_TESTS)) +def test_collMod_write_concern(database_client, collection, test): + """Test collMod writeConcern option 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/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 53963b346..20511f014 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -60,6 +60,7 @@ QUERY_EXCEEDED_MEMORY_NO_DISK_USE_ERROR = 292 API_VERSION_ERROR = 322 API_STRICT_ERROR = 323 +CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR = 359 COLLECTION_UUID_MISMATCH_ERROR = 361 QUERYSETTINGS_QUERY_REJECTED_ERROR = 411 EXPRESSION_NOT_OBJECT_ERROR = 10065 diff --git a/documentdb_tests/framework/preconditions.py b/documentdb_tests/framework/preconditions.py index 8c3bf2534..667853a20 100644 --- a/documentdb_tests/framework/preconditions.py +++ b/documentdb_tests/framework/preconditions.py @@ -53,6 +53,7 @@ "a quorum write concern is accepted (reported as a writeConcernError) rather than " "rejected up front" ), + "oplog": "a replicated oplog (local.oplog.rs) exists", "unforced_compact": "compact succeeds without force", "reindex": "reIndex is permitted", "local_rename": "renaming into the unreplicated local database is permitted", @@ -71,6 +72,7 @@ "cluster_time", "cluster_read_concern", "quorum_write_concern", + "oplog", "replication", } ), diff --git a/documentdb_tests/framework/test_constants.py b/documentdb_tests/framework/test_constants.py index 4583984d7..7e772c09c 100644 --- a/documentdb_tests/framework/test_constants.py +++ b/documentdb_tests/framework/test_constants.py @@ -86,6 +86,7 @@ STRING_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 REGEX_PATTERN_LIMIT_BYTES = 16 * 1024 CLUSTERED_RECORD_ID_LIMIT_BYTES = 8 * 1024 * 1024 +CAPPED_SIZE_LIMIT_BYTES = 2**50 # Int32 lists NUMERIC_INT32_NEGATIVE = [INT32_UNDERFLOW, INT32_MIN] From ce4db22e8a05cb1639ea7331f944280d94c1c30f Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 2 Jul 2026 14:44:51 -0700 Subject: [PATCH 25/51] Add autoCompact command tests (#629) Signed-off-by: Daniel Frankcom --- .../commands/autoCompact/__init__.py | 0 .../test_autoCompact_fstmb_bounds.py | 295 ++++++++++++++++++ .../test_autoCompact_fstmb_overflow.py | 169 ++++++++++ .../test_autoCompact_operational.py | 104 ++++++ .../test_autoCompact_request_validation.py | 221 +++++++++++++ .../autoCompact/test_autoCompact_success.py | 126 ++++++++ .../autoCompact/test_smoke_autoCompact.py | 8 +- .../commands/autoCompact/utils/__init__.py | 0 .../autoCompact/utils/autoCompact_common.py | 33 ++ documentdb_tests/framework/error_codes.py | 1 + 10 files changed, 956 insertions(+), 1 deletion(-) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py new file mode 100644 index 000000000..52372da22 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py @@ -0,0 +1,295 @@ +"""Tests for autoCompact freeSpaceTargetMB numeric coercion and the lower bound.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INT64_UNDERFLOW, + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_HALF, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_HALF, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ONE_AND_HALF, + DOUBLE_ZERO, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT64_MIN, +) + +# Property [freeSpaceTargetMB Accepted Values]: a freeSpaceTargetMB whose +# coerced value is >= 1 is accepted across all numeric BSON types and across the +# int32/int64 boundary. +AUTOCOMPACT_FSTMB_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "fstmb_min_one", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 1}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept the minimum freeSpaceTargetMB of 1", + ), + CommandTestCase( + "fstmb_type_double", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 1.0}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a double freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_type_long", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": Int64(1)}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a long freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_type_decimal", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": Decimal128("1")}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a decimal freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_int32_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT32_MAX}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept int32 max as freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_above_int32_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": Int64(INT32_OVERFLOW)}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a long just above int32 max as freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_decimal_trailing_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_TRAILING_ZERO}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a trailing-zero decimal freeSpaceTargetMB coercing to 1", + ), +] + +# Property [freeSpaceTargetMB Fractional Coercion]: a fractional +# freeSpaceTargetMB is coerced to an integer before validation: doubles +# truncate toward zero and decimals round half-to-even. +AUTOCOMPACT_FSTMB_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "fstmb_double_one_and_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_ONE_AND_HALF}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should truncate a double freeSpaceTargetMB toward zero to an accepted 1", + ), + CommandTestCase( + "fstmb_decimal_just_above_half_full_precision", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": DECIMAL128_JUST_ABOVE_HALF, + }, + expected={"ok": Eq(1.0)}, + msg="autoCompact should round a 34-digit just-above-half decimal up to an accepted 1", + ), + CommandTestCase( + "fstmb_decimal_one_and_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_ONE_AND_HALF}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should round a decimal half-value half-to-even up to an accepted 2", + ), +] + +# Property [freeSpaceTargetMB Value Validation - Lower Bound]: a +# freeSpaceTargetMB whose coerced integer value is below 1 produces a bad-value +# error. +AUTOCOMPACT_FSTMB_LOWER_BOUND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lower_bound_int_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 0}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject an int freeSpaceTargetMB of 0 as below the lower bound", + ), + CommandTestCase( + "lower_bound_int32_min", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT32_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject int32 min freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_int64_min", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT64_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject int64 min freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject a double 0.0 freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_negative_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_NEGATIVE_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce double -0.0 to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject a decimal 0 freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_negative_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_NEGATIVE_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce decimal -0 to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_near_one", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 0.999999}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should truncate a double just below 1 to 0 and reject it as below bound", + ), + CommandTestCase( + "lower_bound_double_negative_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_NEGATIVE_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should truncate double -0.5 to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should round decimal 0.5 half-to-even to 0 and reject it below the bound", + ), + CommandTestCase( + "lower_bound_decimal_negative_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_NEGATIVE_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should round decimal -0.5 half-to-even to 0 and reject it below the bound", + ), + CommandTestCase( + "lower_bound_double_min_subnormal", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_MIN_SUBNORMAL}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should truncate the min subnormal double to 0 and reject it below bound", + ), + CommandTestCase( + "lower_bound_decimal_min_positive", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MIN_POSITIVE}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce the smallest positive decimal to 0 and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_max_negative", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MAX_NEGATIVE}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce the smallest negative decimal to 0 and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_just_below_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_JUST_BELOW_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should round a 34-digit just-below-half decimal to 0 and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_int64_underflow", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": DECIMAL128_INT64_UNDERFLOW, + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate a below-int64-min decimal to int64 min and reject it " + "below bound", + ), + CommandTestCase( + "lower_bound_decimal_min", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate a far-negative decimal to int64 min and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_min_disable", + command=lambda ctx: {"autoCompact": False, "freeSpaceTargetMB": DECIMAL128_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact disable should still enforce the lower bound on a far-negative decimal", + ), + CommandTestCase( + "lower_bound_double_nan", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": FLOAT_NAN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce double NaN to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_negative_infinity", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": FLOAT_NEGATIVE_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate double -Infinity to int64 min and reject it below bound", + ), + CommandTestCase( + "lower_bound_decimal_nan", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_NAN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce decimal NaN to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_negative_infinity", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": DECIMAL128_NEGATIVE_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate decimal -Infinity to int64 min and reject it below bound", + ), +] + +AUTOCOMPACT_FSTMB_BOUNDS_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_FSTMB_ACCEPTED_TESTS + + AUTOCOMPACT_FSTMB_FRACTIONAL_TESTS + + AUTOCOMPACT_FSTMB_LOWER_BOUND_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_FSTMB_BOUNDS_TESTS)) +def test_autoCompact_fstmb_bounds(database_client, collection, test): + """Test autoCompact freeSpaceTargetMB coercion and lower-bound enforcement.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Ensure autoCompact is idle first: a leftover config from a prior test + # would otherwise conflict. + ensure_autocompact_idle(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py new file mode 100644 index 000000000..b9d96d4fb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py @@ -0,0 +1,169 @@ +"""Tests for the autoCompact freeSpaceTargetMB MB-to-bytes overflow boundary.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_INT64_OVERFLOW, + DECIMAL128_MAX, + DOUBLE_FROM_INT64_MAX, + DOUBLE_MAX, + DOUBLE_MAX_SAFE_INTEGER, + FLOAT_INFINITY, + INT64_MAX, +) + +# freeSpaceTargetMB is converted to bytes as a signed int64 (value * 2^20), so +# the largest value whose byte product still fits is INT64_MAX // 2^20 and the +# smallest value that overflows to a negative signed int64 is one past it. +_MB_IN_BYTES = 1 << 20 +_FSTMB_BYTE_OVERFLOW = INT64_MAX // _MB_IN_BYTES + 1 + +# Property [freeSpaceTargetMB Enable/Disable Path Asymmetry]: the MB-to-bytes +# overflow check runs only on the enable path, so a freeSpaceTargetMB whose byte +# product overflows signed int64 is accepted when disabling. +AUTOCOMPACT_FSTMB_PATH_ASYMMETRY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "path_disable_overflow", + command=lambda ctx: { + "autoCompact": False, + "freeSpaceTargetMB": Int64(_FSTMB_BYTE_OVERFLOW), + }, + expected={"ok": Eq(1.0)}, + msg="autoCompact disable should accept a freeSpaceTargetMB whose byte product overflows", + ), +] + +# Property [freeSpaceTargetMB Value Validation - Byte-Level Minimum]: a +# freeSpaceTargetMB that passes the lower bound but whose byte conversion wraps +# back to zero, below the byte minimum, is still rejected. Unlike the overflow +# cases the wrapped product is non-negative, so an implementation guarding only +# against a negative wrap would wrongly accept it; this region needs separate +# coverage. +AUTOCOMPACT_FSTMB_BYTE_MINIMUM_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "byte_minimum_double_max_safe_integer", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": float(DOUBLE_MAX_SAFE_INTEGER), + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject a freeSpaceTargetMB whose byte product wraps to zero", + ), +] + +# Property [freeSpaceTargetMB Overflow Boundary]: the largest freeSpaceTargetMB +# whose byte product still fits in signed int64 is accepted on the enable path. +AUTOCOMPACT_FSTMB_OVERFLOW_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "overflow_accepted_boundary", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": Int64(_FSTMB_BYTE_OVERFLOW - 1), + }, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept the largest freeSpaceTargetMB whose byte product stays " + "positive", + ), +] + +# Property [freeSpaceTargetMB Value Validation - Overflow]: on the enable path, +# a freeSpaceTargetMB whose MB-to-bytes product wraps to a negative signed int64 +# produces a bad-value error. +AUTOCOMPACT_FSTMB_OVERFLOW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "overflow_boundary_plus_one", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": Int64(_FSTMB_BYTE_OVERFLOW), + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the smallest freeSpaceTargetMB whose byte product wraps " + "negative", + ), + CommandTestCase( + "overflow_int64_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT64_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject int64 max freeSpaceTargetMB whose byte product overflows", + ), + CommandTestCase( + "overflow_double_from_int64_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_FROM_INT64_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the int64-max-as-double freeSpaceTargetMB in the overflow " + "region", + ), + CommandTestCase( + "overflow_double_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the max double freeSpaceTargetMB in the overflow region", + ), + CommandTestCase( + "overflow_double_infinity", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": FLOAT_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject double +Infinity freeSpaceTargetMB in the overflow region", + ), + CommandTestCase( + "overflow_decimal_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the max decimal freeSpaceTargetMB in the overflow region", + ), + CommandTestCase( + "overflow_decimal_int64_overflow", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_INT64_OVERFLOW}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should absorb an above-int64-max decimal freeSpaceTargetMB into the " + "overflow path", + ), + CommandTestCase( + "overflow_decimal_infinity", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject decimal Infinity freeSpaceTargetMB in the overflow region", + ), +] + +AUTOCOMPACT_FSTMB_OVERFLOW_BOUNDARY_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_FSTMB_PATH_ASYMMETRY_TESTS + + AUTOCOMPACT_FSTMB_BYTE_MINIMUM_TESTS + + AUTOCOMPACT_FSTMB_OVERFLOW_ACCEPTED_TESTS + + AUTOCOMPACT_FSTMB_OVERFLOW_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_FSTMB_OVERFLOW_BOUNDARY_TESTS)) +def test_autoCompact_fstmb_overflow(database_client, collection, test): + """Test autoCompact freeSpaceTargetMB MB-to-bytes overflow boundary behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Ensure autoCompact is idle first: a leftover config from a prior test + # would otherwise conflict. + ensure_autocompact_idle(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py new file mode 100644 index 000000000..bd7a8bde0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py @@ -0,0 +1,104 @@ +"""Tests for autoCompact operational constraints: admin scope and reconfigure conflict.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CONFLICTING_OPERATION_IN_PROGRESS_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import ( + execute_admin_command, + execute_command, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Admin-Scope Errors]: a fully valid autoCompact run against a +# non-admin database is rejected as out of scope. +AUTOCOMPACT_ADMIN_SCOPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "admin_scope_enable", + command=lambda ctx: {"autoCompact": True}, + error_code=UNAUTHORIZED_ERROR, + msg="autoCompact enable should be rejected when run against a non-admin database", + ), + CommandTestCase( + "admin_scope_disable", + command=lambda ctx: {"autoCompact": False}, + error_code=UNAUTHORIZED_ERROR, + msg="autoCompact disable should be rejected when run against a non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_ADMIN_SCOPE_TESTS)) +def test_autoCompact_admin_scope(database_client, collection, test): + """Test autoCompact admin-scope rejection against a non-admin database.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Run against the fixture's non-admin database so the admin-scope check can + # fire. It happens at dispatch, independent of compaction state, so no + # settling is needed here. + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Running-State Reconfigure Conflict]: reconfiguring an enabled +# autoCompact with a different config is rejected with a conflict error rather +# than silently overriding the running config. +@pytest.mark.no_parallel +def test_autoCompact_reconfigure_conflict(collection): + """Test autoCompact rejects a differing reconfigure while enabled.""" + ensure_autocompact_idle(collection) + + # Establish a running config. + execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + + # Second should be rejected as the value differs. + result = execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 50}) + + assertResult( + result, + error_code=CONFLICTING_OPERATION_IN_PROGRESS_ERROR, + msg="autoCompact should reject reconfiguring an enabled compaction with a different config", + raw_res=True, + ) + + +# Property [Running-State Idempotent Reconfigure]: re-enabling an already +# enabled autoCompact with an identical config is accepted as a no-op rather +# than rejected, confirming the reconfigure conflict is driven by the config +# difference and not the already-running state. +@pytest.mark.no_parallel +def test_autoCompact_reconfigure_idempotent(collection): + """Test autoCompact accepts re-enabling an enabled compaction with an identical config.""" + ensure_autocompact_idle(collection) + + # Establish a running config. + execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + + # Second should be accepted as a no-op since the value is the same. + result = execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept re-enabling an enabled compaction with an identical config", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py new file mode 100644 index 000000000..4ed6528fc --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py @@ -0,0 +1,221 @@ +"""Tests for autoCompact request validation: type strictness, null, and bad fields.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DOUBLE_ZERO, INT32_ZERO + +# Property [Null Command Value]: a null autoCompact command value is treated as +# a missing required field rather than a wrong type. +AUTOCOMPACT_NULL_COMMAND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_command_value", + command=lambda ctx: {"autoCompact": None}, + error_code=MISSING_FIELD_ERROR, + msg="autoCompact should reject a null command value as a missing required field", + ), +] + +# Property [Value Type Strictness]: every non-bool BSON type for the autoCompact +# command value is rejected with a type mismatch error rather than coerced to a +# boolean, and a literal array is rejected without unwrapping. +AUTOCOMPACT_VALUE_TYPE_STRICTNESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"value_type_{tid}", + command=lambda ctx, v=val: {"autoCompact": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"autoCompact should reject a {tid} command value as the wrong type", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("string", "true"), + ("object", {"a": 1}), + ("array", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("x")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "value_array_single_bool", + command=lambda ctx: {"autoCompact": [True]}, + error_code=TYPE_MISMATCH_ERROR, + msg="autoCompact should reject a single-element bool array without unwrapping it", + ), +] + +# Property [freeSpaceTargetMB Type Strictness]: every non-numeric BSON type for +# freeSpaceTargetMB is rejected with a type mismatch error rather than coerced +# to a number, and a literal array is rejected without unwrapping. +AUTOCOMPACT_FSTMB_TYPE_STRICTNESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"fstmb_type_{tid}", + command=lambda ctx, v=val: {"autoCompact": True, "freeSpaceTargetMB": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"autoCompact should reject a {tid} freeSpaceTargetMB as the wrong type", + ) + for tid, val in [ + ("string", "20"), + ("bool", True), + ("object", {"a": 1}), + ("array", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("x")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "fstmb_array_single_int", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": [20]}, + error_code=TYPE_MISMATCH_ERROR, + msg="autoCompact should reject a single-element int array freeSpaceTargetMB without unwrap", + ), +] + +# Property [runOnce Type Strictness]: every non-bool BSON type for runOnce is +# rejected with a type mismatch error rather than coerced to a boolean, so +# numeric 0 and 1 are not accepted. +AUTOCOMPACT_RUNONCE_TYPE_STRICTNESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"runonce_type_{tid}", + command=lambda ctx, v=val: {"autoCompact": True, "runOnce": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"autoCompact should reject a {tid} runOnce as the wrong type", + ) + for tid, val in [ + ("string", "true"), + ("int32_zero", INT32_ZERO), + ("int32_one", 1), + ("int64", Int64(1)), + ("double_zero", DOUBLE_ZERO), + ("double_one", 1.0), + ("decimal128", Decimal128("1")), + ("object", {"$exists": True}), + ("array", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("x")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "runonce_array_single_bool", + command=lambda ctx: {"autoCompact": True, "runOnce": [True]}, + error_code=TYPE_MISMATCH_ERROR, + msg="autoCompact should reject a single-element bool array runOnce without unwrapping it", + ), +] + +# Property [Unknown Field Handling]: an unknown top-level field is rejected with +# an unknown-field error, and known option names are case-sensitive so wrong-case +# variants are treated as unknown fields and rejected the same way. +AUTOCOMPACT_UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field", + command=lambda ctx: {"autoCompact": True, "bogusField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="autoCompact should reject an unknown top-level field", + ), + CommandTestCase( + "unknown_field_fstmb_capitalized", + command=lambda ctx: {"autoCompact": True, "FreeSpaceTargetMB": 20}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="autoCompact should treat a wrong-case variant of a known option as an unknown field", + ), +] + +# Property [Generic Envelope Field Rejection]: a writeConcern envelope field is +# rejected with an unsupported-options error on both the enable and disable +# paths. +AUTOCOMPACT_WRITE_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "write_concern_enable", + command=lambda ctx: {"autoCompact": True, "writeConcern": {"w": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="autoCompact enable should reject writeConcern as an unsupported envelope field", + ), + CommandTestCase( + "write_concern_disable", + command=lambda ctx: {"autoCompact": False, "writeConcern": {"w": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="autoCompact disable should reject writeConcern as an unsupported envelope field", + ), +] + +AUTOCOMPACT_REQUEST_VALIDATION_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_NULL_COMMAND_TESTS + + AUTOCOMPACT_VALUE_TYPE_STRICTNESS_TESTS + + AUTOCOMPACT_FSTMB_TYPE_STRICTNESS_TESTS + + AUTOCOMPACT_RUNONCE_TYPE_STRICTNESS_TESTS + + AUTOCOMPACT_UNKNOWN_FIELD_TESTS + + AUTOCOMPACT_WRITE_CONCERN_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_REQUEST_VALIDATION_TESTS)) +def test_autoCompact_request_validation(database_client, collection, test): + """Test autoCompact rejection of malformed requests (type, null, bad fields).""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Ensure autoCompact is idle first: a leftover config from a prior test + # would otherwise conflict. + ensure_autocompact_idle(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py new file mode 100644 index 000000000..b24e6c915 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py @@ -0,0 +1,126 @@ +"""Tests for the autoCompact command: successful requests and accepted inputs.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType, NotExists + +# Property [Response Format]: a successful autoCompact returns a bare ok +# response with no command-specific result fields. +AUTOCOMPACT_RESPONSE_FORMAT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "response_enable", + command=lambda ctx: {"autoCompact": True}, + expected={ + "ok": [Eq(1.0), IsType("double")], + "autoCompact": NotExists(), + }, + msg="autoCompact enable should return ok:1.0 as a double with no command-specific fields", + ), + CommandTestCase( + "response_disable", + command=lambda ctx: {"autoCompact": False}, + expected={ + "ok": [Eq(1.0), IsType("double")], + "autoCompact": NotExists(), + }, + msg="autoCompact disable should return ok:1.0 as a double with no command-specific fields", + ), + CommandTestCase( + "response_enable_with_options", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": 1, + "runOnce": False, + }, + expected={ + "ok": [Eq(1.0), IsType("double")], + "autoCompact": NotExists(), + "freeSpaceTargetMB": NotExists(), + "runOnce": NotExists(), + }, + msg="autoCompact enable with options should not echo freeSpaceTargetMB or runOnce", + ), +] + +# Property [Null Optional Fields]: a null freeSpaceTargetMB or runOnce is +# accepted and treated identically to omitting the field. +AUTOCOMPACT_NULL_OPTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_fstmb", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": None}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should treat a null freeSpaceTargetMB as omitted", + ), + CommandTestCase( + "null_runonce", + command=lambda ctx: {"autoCompact": True, "runOnce": None}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should treat a null runOnce as omitted", + ), +] + +# Property [Value Behavior]: runOnce:true is accepted alongside a disable +# (autoCompact:false) rather than rejected as contradictory. +AUTOCOMPACT_VALUE_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "value_disable_with_runonce", + command=lambda ctx: {"autoCompact": False, "runOnce": True}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept runOnce:true alongside a disable and return ok:1.0", + ), +] + +# Property [runOnce Accepted Values]: both runOnce:true and runOnce:false are +# accepted on the enable path. +AUTOCOMPACT_RUNONCE_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "runonce_true", + command=lambda ctx: {"autoCompact": True, "runOnce": True}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept runOnce:true and return ok:1.0", + ), + CommandTestCase( + "runonce_false", + command=lambda ctx: {"autoCompact": True, "runOnce": False}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept runOnce:false and return ok:1.0", + ), +] + +AUTOCOMPACT_SUCCESS_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_RESPONSE_FORMAT_TESTS + + AUTOCOMPACT_NULL_OPTIONAL_TESTS + + AUTOCOMPACT_VALUE_BEHAVIOR_TESTS + + AUTOCOMPACT_RUNONCE_ACCEPTED_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_SUCCESS_TESTS)) +def test_autoCompact_success(database_client, collection, test): + """Test autoCompact successful requests and accepted inputs.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Ensure autoCompact is idle first: a leftover config from a prior test + # would otherwise conflict. + ensure_autocompact_idle(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py index 06d752df2..5bb8b7954 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py @@ -6,14 +6,20 @@ import pytest +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) from documentdb_tests.framework.assertions import assertSuccessPartial from documentdb_tests.framework.executor import execute_admin_command -pytestmark = pytest.mark.smoke +pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel] def test_smoke_autoCompact(collection): """Test basic autoCompact behavior.""" + # Ensure autoCompact is idle first: a leftover non-default config would make + # this plain enable conflict instead of returning ok. + ensure_autocompact_idle(collection) result = execute_admin_command(collection, {"autoCompact": True}) expected = {"ok": 1.0} diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py new file mode 100644 index 000000000..4b325ed7a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py @@ -0,0 +1,33 @@ +"""Shared helpers for autoCompact command tests.""" + +from __future__ import annotations + +import time + +from documentdb_tests.framework.executor import execute_admin_command + + +def ensure_autocompact_idle(collection): + """Disable autoCompact, retrying until it reaches a deterministic idle state. + + autoCompact is a server-wide setting, so a test inherits prior state, and a + single disable returns before the background wind-down finishes. This sends + disable repeatedly with a short pause between calls, returns only after + several consecutive disables succeed (so the async wind-down has time to + finish), and raises if it never settles within a bounded number of attempts. + + Callers must be marked no_parallel: this only resets state left by a prior + test, not a concurrent worker mutating the shared setting between settling + and the command under test. + """ + consecutive = 0 + for _ in range(200): + result = execute_admin_command(collection, {"autoCompact": False}) + if isinstance(result, dict) and result.get("ok") == 1.0: + consecutive += 1 + if consecutive >= 3: + return + else: + consecutive = 0 + time.sleep(0.05) + raise RuntimeError("autoCompact did not reach an idle state") diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 20511f014..da1ad5976 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -14,6 +14,7 @@ OVERFLOW_ERROR = 15 INVALID_LENGTH_ERROR = 16 ILLEGAL_OPERATION_ERROR = 20 +CONFLICTING_OPERATION_IN_PROGRESS_ERROR = 23 NAMESPACE_NOT_FOUND_ERROR = 26 INDEX_NOT_FOUND_ERROR = 27 PATH_NOT_VIABLE_ERROR = 28 From df5f31bc14a0800c22706d7074b93cf59ce1571b Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Thu, 2 Jul 2026 15:02:12 -0700 Subject: [PATCH 26/51] Add query and write tests for bulkWrite (#630) Signed-off-by: Victor [C] Tsang --- .../operations/test_operations_bulk_write.py | 106 +-- .../test_bulkWrite_argument_validation.py | 113 ++++ .../test_bulkWrite_bson_type_validation.py | 380 +++++++++++ .../test_bulkWrite_bypass_validation.py | 389 +++++++++++ .../bulkWrite/test_bulkWrite_core_delete.py | 106 +++ .../bulkWrite/test_bulkWrite_core_insert.py | 313 +++++++++ .../bulkWrite/test_bulkWrite_core_update.py | 281 ++++++++ .../bulkWrite/test_bulkWrite_errors.py | 626 ++++++++++++++++++ .../bulkWrite/test_bulkWrite_let_variables.py | 228 +++++++ .../test_bulkWrite_mixed_operations.py | 208 ++++++ .../test_bulkWrite_response_structure.py | 200 ++++++ .../bulkWrite/test_bulkWrite_sub_features.py | 203 ++++++ 12 files changed, 3105 insertions(+), 48 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py diff --git a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py index 49a630c78..1e6fbc44d 100644 --- a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py +++ b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py @@ -1,4 +1,4 @@ -"""Tests for collation in bulkWrite operations.""" +"""Tests for collation in the bulkWrite command.""" from __future__ import annotations @@ -8,13 +8,14 @@ CommandContext, CommandTestCase, ) -from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.target_collection import CustomCollection # Property [BulkWrite Update Collation]: individual update operations within a -# bulkWrite can specify collation, affecting filter matching independently. +# bulkWrite command can specify collation, affecting filter matching +# independently of other operations in the same command. COLLATION_BULK_UPDATE_TESTS: list[CommandTestCase] = [ CommandTestCase( "bulk_update_case_insensitive", @@ -23,21 +24,24 @@ {"_id": 2, "x": "banana", "v": 1}, ], command=lambda ctx: { - "update": ctx.collection, - "updates": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "u": {"$set": {"v": 2}}, + "update": 0, + "filter": {"x": "apple"}, + "updateMods": {"$set": {"v": 2}}, "collation": {"locale": "en", "strength": 2}, }, { - "q": {"x": "BANANA"}, - "u": {"$set": {"v": 3}}, + "update": 0, + "filter": {"x": "BANANA"}, + "updateMods": {"$set": {"v": 3}}, "collation": {"locale": "en", "strength": 2}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 2, "nModified": 2}, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, msg="bulkWrite updates should each use their own collation", ), CommandTestCase( @@ -47,26 +51,30 @@ {"_id": 2, "x": "banana", "v": 1}, ], command=lambda ctx: { - "update": ctx.collection, - "updates": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "u": {"$set": {"v": 2}}, + "update": 0, + "filter": {"x": "apple"}, + "updateMods": {"$set": {"v": 2}}, "collation": {"locale": "en", "strength": 2}, }, { - "q": {"x": "BANANA"}, - "u": {"$set": {"v": 3}}, + "update": 0, + "filter": {"x": "BANANA"}, + "updateMods": {"$set": {"v": 3}}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1, "nModified": 1}, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, msg="bulkWrite with mixed collation: only collated op should match case-insensitively", ), ] # Property [BulkWrite Delete Collation]: individual delete operations within a -# bulkWrite can specify collation, affecting filter matching independently. +# bulkWrite command can specify collation, affecting filter matching +# independently of other operations in the same command. COLLATION_BULK_DELETE_TESTS: list[CommandTestCase] = [ CommandTestCase( "bulk_delete_case_insensitive", @@ -76,16 +84,18 @@ {"_id": 3, "x": "cherry"}, ], command=lambda ctx: { - "delete": ctx.collection, - "deletes": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "limit": 0, + "delete": 0, + "filter": {"x": "apple"}, + "multi": True, "collation": {"locale": "en", "strength": 2}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1}, + expected={"ok": 1.0, "nDeleted": 1}, msg="bulkWrite delete with collation should match case-insensitively", ), CommandTestCase( @@ -96,15 +106,17 @@ {"_id": 3, "x": "banana"}, ], command=lambda ctx: { - "delete": ctx.collection, - "deletes": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "limit": 0, + "delete": 0, + "filter": {"x": "apple"}, + "multi": True, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1}, + expected={"ok": 1.0, "nDeleted": 1}, msg="bulkWrite delete without collation should use binary comparison", ), ] @@ -120,15 +132,17 @@ {"_id": 2, "x": "banana", "v": 1}, ], command=lambda ctx: { - "update": ctx.collection, - "updates": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "u": {"$set": {"v": 2}}, + "update": 0, + "filter": {"x": "apple"}, + "updateMods": {"$set": {"v": 2}}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1, "nModified": 1}, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, msg="bulkWrite update should inherit collection default collation", ), CommandTestCase( @@ -139,15 +153,17 @@ {"_id": 2, "x": "banana"}, ], command=lambda ctx: { - "delete": ctx.collection, - "deletes": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "limit": 0, + "delete": 0, + "filter": {"x": "apple"}, + "multi": True, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1}, + expected={"ok": 1.0, "nDeleted": 1}, msg="bulkWrite delete should inherit collection default collation", ), ] @@ -161,14 +177,8 @@ @pytest.mark.parametrize("test", pytest_params(COLLATION_BULK_WRITE_TESTS)) def test_collation_bulk_write(database_client, collection, test): - """Test collation behavior in bulkWrite operations.""" + """Test collation behavior in the bulkWrite command.""" 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, - ) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py new file mode 100644 index 000000000..775a74499 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py @@ -0,0 +1,113 @@ +"""Tests for bulkWrite argument acceptance — valid ops, nsInfo, and optional fields.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +BULKWRITE_ARGUMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ops_single_operation", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should accept ops with a single operation", + ), + CommandTestCase( + "ops_many_operations", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": i}} for i in range(50)], + }, + expected={"ok": 1.0, "nInserted": 50}, + msg="bulkWrite should accept ops with many operations", + ), + CommandTestCase( + "let_with_document", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$targetVal"]}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + "let": {"targetVal": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should accept a let document with variables", + ), + CommandTestCase( + "ops_only_updates", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 10}}}, + {"update": 0, "filter": {"_id": 2}, "updateMods": {"$set": {"x": 20}}}, + ], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite should accept an ops array of only updates", + ), + CommandTestCase( + "ops_only_deletes", + docs=[{"_id": 1}, {"_id": 2}], + command={ + "bulkWrite": 1, + "ops": [ + {"delete": 0, "filter": {"_id": 1}}, + {"delete": 0, "filter": {"_id": 2}}, + ], + }, + expected={"ok": 1.0, "nDeleted": 2}, + msg="bulkWrite should accept an ops array of only deletes", + ), + CommandTestCase( + "writeConcern_valid", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "writeConcern": {"w": 1}, + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should accept a valid writeConcern", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_ARGUMENT_TESTS)) +def test_bulkWrite_argument_validation(database_client, collection, test): + """Test bulkWrite argument acceptance — valid ops, nsInfo, and optional fields.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_accepts_unused_nsInfo_entry(collection): + """Test bulkWrite accepts an nsInfo array with more namespaces than the ops reference.""" + ns = f"{collection.database.name}.{collection.name}" + ns_unused = f"{collection.database.name}.{collection.name}_unused" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": ns}, {"ns": ns_unused}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should accept an nsInfo entry that no op references", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py new file mode 100644 index 000000000..6657bb102 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py @@ -0,0 +1,380 @@ +"""Tests for bulkWrite BSON type validation of command, operation, and namespace fields. + +Verifies that each bulkWrite input field accepts its valid BSON types and rejects +all other types with the correct error code, using the shared BSON type harness. +""" + +from typing import Any + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, + DOUBLE_ZERO, + INT32_ZERO, + INT64_ZERO, +) + +COMMAND_FIELD_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="command_bulkWrite", + keyword="bulkWrite", + msg="bulkWrite dispatches on the first field being named 'bulkWrite'; its value is " + "ignored, so the command field accepts any BSON type", + valid_types=list(BsonType), + ), + BsonTypeTestCase( + id="command_ordered", + keyword="ordered", + msg="bulkWrite ordered accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_bypassDocumentValidation", + keyword="bypassDocumentValidation", + msg="bulkWrite bypassDocumentValidation accepts bool, null, and numeric types", + valid_types=[ + BsonType.BOOL, + BsonType.NULL, + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_comment", + keyword="comment", + msg="bulkWrite comment accepts any BSON type", + valid_types=list(BsonType), + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_let", + keyword="let", + msg="bulkWrite let accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_errorsOnly", + keyword="errorsOnly", + msg="bulkWrite errorsOnly accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_cursor", + keyword="cursor", + msg="bulkWrite cursor accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"batchSize": 1}}, + ), + BsonTypeTestCase( + id="command_writeConcern", + keyword="writeConcern", + msg="bulkWrite writeConcern accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"w": 1}}, + ), +] + +NAMESPACE_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="command_nsInfo", + msg="bulkWrite nsInfo accepts only an array", + valid_types=[BsonType.ARRAY], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), + BsonTypeTestCase( + id="nsInfo_ns", + msg="bulkWrite nsInfo.ns accepts only a string", + valid_types=[BsonType.STRING], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +INSERT_OP_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="op_insert_index", + msg="bulkWrite insert namespace index accepts only numeric types", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + valid_inputs={ + BsonType.DOUBLE: DOUBLE_ZERO, + BsonType.INT: INT32_ZERO, + BsonType.LONG: INT64_ZERO, + BsonType.DECIMAL: DECIMAL128_ZERO, + }, + ), + BsonTypeTestCase( + id="op_document", + msg="bulkWrite insert document accepts only a document", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +UPDATE_OP_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="op_update_index", + msg="bulkWrite update namespace index accepts only numeric types", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + valid_inputs={ + BsonType.DOUBLE: DOUBLE_ZERO, + BsonType.INT: INT32_ZERO, + BsonType.LONG: INT64_ZERO, + BsonType.DECIMAL: DECIMAL128_ZERO, + }, + ), + BsonTypeTestCase( + id="op_filter", + msg="bulkWrite update filter accepts only a document", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), + BsonTypeTestCase( + id="op_updateMods", + msg="bulkWrite updateMods accepts a document or an aggregation pipeline array", + valid_types=[BsonType.OBJECT, BsonType.ARRAY], + default_error_code=FAILED_TO_PARSE_ERROR, + valid_inputs={BsonType.ARRAY: [{"$set": {"x": 1}}]}, + ), + BsonTypeTestCase( + id="op_arrayFilters", + msg="bulkWrite arrayFilters accepts only an array or null", + valid_types=[BsonType.ARRAY, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.ARRAY: []}, + ), + BsonTypeTestCase( + id="op_multi", + msg="bulkWrite multi accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="op_hint", + msg="bulkWrite hint accepts only a string or a document", + valid_types=[BsonType.STRING, BsonType.OBJECT], + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="op_constants", + msg="bulkWrite constants accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="op_collation", + msg="bulkWrite collation accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"locale": "en"}}, + ), + BsonTypeTestCase( + id="op_upsert", + msg="bulkWrite upsert accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +DELETE_OP_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="op_delete_index", + msg="bulkWrite delete namespace index accepts only numeric types", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + valid_inputs={ + BsonType.DOUBLE: DOUBLE_ZERO, + BsonType.INT: INT32_ZERO, + BsonType.LONG: INT64_ZERO, + BsonType.DECIMAL: DECIMAL128_ZERO, + }, + ), + BsonTypeTestCase( + id="op_delete_filter", + msg="bulkWrite delete filter accepts only a document", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), + BsonTypeTestCase( + id="op_delete_multi", + msg="bulkWrite delete multi accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="op_delete_hint", + msg="bulkWrite delete hint accepts only a string or a document", + valid_types=[BsonType.STRING, BsonType.OBJECT], + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="op_delete_collation", + msg="bulkWrite delete collation accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"locale": "en"}}, + ), +] + +BULKWRITE_TYPE_PARAMS: list[BsonTypeTestCase] = ( + COMMAND_FIELD_PARAMS + NAMESPACE_PARAMS + INSERT_OP_PARAMS + UPDATE_OP_PARAMS + DELETE_OP_PARAMS +) + + +def _inject_command_nsInfo( + command: dict[str, Any], bson_type: BsonType, sample_value: Any, namespace: str +) -> None: + """Replace the whole ``nsInfo`` array (valid ARRAY uses the live namespace).""" + command["nsInfo"] = [{"ns": namespace}] if bson_type == BsonType.ARRAY else sample_value + + +def _inject_nsInfo_ns( + command: dict[str, Any], bson_type: BsonType, sample_value: Any, namespace: str +) -> None: + """Set ``nsInfo[0].ns`` (valid STRING uses the live namespace).""" + ns_val = namespace if bson_type == BsonType.STRING else sample_value + command["nsInfo"] = [{"ns": ns_val}] + + +def _inject_op_field(command: dict[str, Any], spec_id: str, sample_value: Any) -> None: + """Build the op under test with ``sample_value`` and set it as ``ops[0]``. + + Field order matters: the op discriminator (insert/update/delete) must be first, so + ``sample_value`` leads the ``*_index`` specs. + """ + ops_by_spec: dict[str, dict[str, Any]] = { + "op_insert_index": {"insert": sample_value, "document": {"_id": 1}}, + "op_document": {"insert": 0, "document": sample_value}, + "op_update_index": {"update": sample_value, "filter": {}, "updateMods": {"$set": {"x": 1}}}, + "op_filter": {"update": 0, "filter": sample_value, "updateMods": {"$set": {"x": 1}}}, + "op_updateMods": {"update": 0, "filter": {}, "updateMods": sample_value}, + "op_arrayFilters": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "arrayFilters": sample_value, + }, + "op_multi": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "multi": sample_value, + }, + "op_hint": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "hint": sample_value, + }, + "op_constants": { + "update": 0, + "filter": {}, + "updateMods": [{"$set": {"x": 1}}], + "constants": sample_value, + }, + "op_collation": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "collation": sample_value, + }, + "op_upsert": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "upsert": sample_value, + }, + "op_delete_index": {"delete": sample_value, "filter": {}}, + "op_delete_filter": {"delete": 0, "filter": sample_value}, + "op_delete_multi": {"delete": 0, "filter": {}, "multi": sample_value}, + "op_delete_hint": {"delete": 0, "filter": {}, "hint": sample_value}, + "op_delete_collation": {"delete": 0, "filter": {}, "collation": sample_value}, + } + command["ops"] = [ops_by_spec[spec_id]] + + +def _inject_top_level(command: dict[str, Any], spec: BsonTypeTestCase, sample_value: Any) -> None: + """Set a top-level command field directly on the command document.""" + if spec.keyword is None: + raise ValueError(f"top-level spec {spec.id!r} must define a keyword") + command[spec.keyword] = sample_value + + +def _build_command( + spec: BsonTypeTestCase, bson_type: BsonType, sample_value: Any, namespace: str +) -> dict[str, Any]: + """Build a bulkWrite command injecting ``sample_value`` into the field named by ``spec``.""" + command: dict[str, Any] = { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": namespace}], + } + + if spec.id == "command_nsInfo": + _inject_command_nsInfo(command, bson_type, sample_value, namespace) + elif spec.id == "nsInfo_ns": + _inject_nsInfo_ns(command, bson_type, sample_value, namespace) + elif spec.id.startswith("op_"): + _inject_op_field(command, spec.id, sample_value) + else: + _inject_top_level(command, spec, sample_value) + + return command + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(BULKWRITE_TYPE_PARAMS) +) +def test_bulkWrite_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test bulkWrite rejects invalid BSON types for each input field with the correct code.""" + namespace = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, _build_command(spec, bson_type, sample_value, namespace) + ) + assertFailureCode( + result, spec.expected_code(bson_type), msg=f"{spec.msg}: rejects {bson_type.value}" + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(BULKWRITE_TYPE_PARAMS) +) +def test_bulkWrite_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test bulkWrite accepts valid BSON types for each input field.""" + namespace = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, _build_command(spec, bson_type, sample_value, namespace) + ) + assertSuccessPartial( + result, {"ok": 1.0, "nErrors": 0}, msg=f"{spec.msg}: accepts {bson_type.value}" + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py new file mode 100644 index 000000000..243c9d988 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py @@ -0,0 +1,389 @@ +"""Tests for bulkWrite bypassDocumentValidation value coercion and type rejection. + +Mirrors ``aggregate/test_aggregate_bypass_validation.py``. bulkWrite-specific: a validator +failure surfaces as an op-level error (``nErrors:1``, ``cursor.firstBatch.0.code`` 121). +""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + DOCUMENT_VALIDATION_FAILURE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import CustomCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_ZERO, +) + +_VALIDATOR = {"$jsonSchema": {"bsonType": "object", "required": ["name"]}} + + +def _validated_collection() -> CustomCollection: + return CustomCollection(options={"validator": _VALIDATOR}) + + +_BYPASS = {"ok": Eq(1.0), "nErrors": Eq(0), "nInserted": Eq(1)} + +BULKWRITE_BYPASS_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "bypass_truthy_true", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": True, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=true (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_int32_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": 1, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=int32 1 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_int64_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": Int64(1), + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Int64 1 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_double_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": 1.0, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=double 1.0 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_decimal128_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": Decimal128("1"), + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 1 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=NaN (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_neg_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_NEGATIVE_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=-NaN (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_decimal128_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 NaN (truthy) should bypass", + ), + CommandTestCase( + "bypass_truthy_decimal128_neg_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NEGATIVE_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 -NaN (truthy) should bypass", + ), + CommandTestCase( + "bypass_truthy_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Infinity (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_neg_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_NEGATIVE_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=-Infinity (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_decimal128_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 Infinity (truthy) should bypass", + ), + CommandTestCase( + "bypass_truthy_decimal128_neg_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NEGATIVE_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 -Infinity (truthy) should bypass", + ), +] + +_ENFORCE = { + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DOCUMENT_VALIDATION_FAILURE_ERROR), +} + +BULKWRITE_BYPASS_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "bypass_falsy_false", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": False, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=false (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_int32_0", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": INT32_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=int32 0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_double_0", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DOUBLE_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=double 0.0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_double_neg_zero", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DOUBLE_NEGATIVE_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=double -0.0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_decimal128_0", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=Decimal128 0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_decimal128_neg_zero", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NEGATIVE_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=Decimal128 -0 (falsy) should enforce the validator", + ), +] + +_OPS = [{"insert": 0, "document": {"_id": 1}}] + +BULKWRITE_BYPASS_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "bypass_reject_string", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": "hello"}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject string for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_array", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": [1, 2]}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject array for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_document", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": {"a": 1}}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject document for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_objectid", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject objectid for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_datetime", + command={ + "bulkWrite": 1, + "ops": _OPS, + "bypassDocumentValidation": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject datetime for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_timestamp", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Timestamp(1, 1)}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject timestamp for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_regex", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Regex(".*")}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject regex for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_binary", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Binary(b"hello")}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject binary for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_code", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject code for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_code_with_scope", + command={ + "bulkWrite": 1, + "ops": _OPS, + "bypassDocumentValidation": Code("function(){}", {"x": 1}), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject code_with_scope for bypassDocumentValidation", + ), + CommandTestCase( + "bypass_reject_minkey", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject minkey for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_maxkey", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject maxkey for bypassDocumentValidation with TypeMismatch", + ), +] + +BULKWRITE_BYPASS_VALIDATION_TESTS = ( + BULKWRITE_BYPASS_TRUTHY_TESTS + BULKWRITE_BYPASS_FALSY_TESTS + BULKWRITE_BYPASS_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_BYPASS_VALIDATION_TESTS)) +def test_bulkWrite_bypass_validation(database_client, collection, test): + """Test bulkWrite bypassDocumentValidation value coercion and type rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + 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/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py new file mode 100644 index 000000000..0610402ab --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py @@ -0,0 +1,106 @@ +"""Tests for bulkWrite core delete operations.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +BULKWRITE_DELETE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_delete", + docs=[{"_id": 1, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"_id": 1}}]}, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite should perform a single delete", + ), + CommandTestCase( + "delete_no_match", + docs=[{"_id": 1, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"_id": 999}}]}, + expected={"ok": 1.0, "nDeleted": 0}, + msg="bulkWrite delete with a non-matching filter should delete nothing", + ), + CommandTestCase( + "delete_multi_true", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}, "multi": True}]}, + expected={"ok": 1.0, "nDeleted": 2}, + msg="bulkWrite delete with multi:true should delete all matching documents", + ), + CommandTestCase( + "delete_multi_false", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}, "multi": False}]}, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite delete with multi:false should delete only the first match", + ), + CommandTestCase( + "delete_nonexistent_collection", + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}}]}, + expected={"ok": 1.0, "nDeleted": 0}, + msg="bulkWrite delete on a non-existent collection should delete nothing", + ), + CommandTestCase( + "delete_empty_collection", + docs=[], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}}]}, + expected={"ok": 1.0, "nDeleted": 0}, + msg="bulkWrite delete on an empty collection should delete nothing", + ), + CommandTestCase( + "insert_then_delete", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "x": 1}}, + {"delete": 0, "filter": {"_id": 1}}, + ], + }, + expected={"ok": 1.0, "nInserted": 1, "nDeleted": 1}, + msg="bulkWrite should insert then delete the same document", + ), + CommandTestCase( + "delete_without_multi_deletes_one", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}, {"_id": 3, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}}]}, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite delete without the multi flag should delete only one document", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_DELETE_TESTS)) +def test_bulkWrite_core_delete(database_client, collection, test): + """Test bulkWrite core delete operations.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_delete_namespace_isolation(collection): + """Test delete targeting one namespace does not affect documents in a different namespace.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) + sibling.insert_one({"_id": 1, "x": 1}) + ns_a = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"_id": 1}}], + "nsInfo": [{"ns": ns_a}], + }, + ) + other_ns = execute_command(sibling, {"find": sibling.name, "filter": {}}) + assertSuccess(other_ns, [{"_id": 1, "x": 1}]) + sibling.drop() diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py new file mode 100644 index 000000000..7cb6d9ec2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py @@ -0,0 +1,313 @@ +"""Tests for bulkWrite core insert operations, data type coverage, and document edge cases.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import CappedCollection +from documentdb_tests.framework.test_constants import BSON_TYPE_SAMPLES, BsonType + +BULKWRITE_INSERT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "insert_double", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.DOUBLE]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a double field value", + ), + CommandTestCase( + "insert_string", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.STRING]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a string field value", + ), + CommandTestCase( + "insert_object", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.OBJECT]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a object field value", + ), + CommandTestCase( + "insert_array", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.ARRAY]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a array field value", + ), + CommandTestCase( + "insert_bindata", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.BIN_DATA]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a bindata field value", + ), + CommandTestCase( + "insert_objectid", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.OBJECT_ID]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a objectid field value", + ), + CommandTestCase( + "insert_boolean", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.BOOL]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a boolean field value", + ), + CommandTestCase( + "insert_date", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.DATE]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a date field value", + ), + CommandTestCase( + "insert_null", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.NULL]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a null field value", + ), + CommandTestCase( + "insert_regex", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.REGEX]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a regex field value", + ), + CommandTestCase( + "insert_int", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.INT]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a int field value", + ), + CommandTestCase( + "insert_long", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.LONG]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a long field value", + ), + CommandTestCase( + "insert_timestamp", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.TIMESTAMP]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a timestamp field value", + ), + CommandTestCase( + "insert_decimal128", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.DECIMAL]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a decimal128 field value", + ), + CommandTestCase( + "insert_minkey", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.MIN_KEY]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a minkey field value", + ), + CommandTestCase( + "insert_maxkey", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.MAX_KEY]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a maxkey field value", + ), + CommandTestCase( + "insert_javascript", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.JAVASCRIPT]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a javascript field value", + ), +] + +BULKWRITE_INSERT_CORE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_insert", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "a": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should perform a single insert", + ), + CommandTestCase( + "multiple_inserts", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "a": 1}}, + {"insert": 0, "document": {"_id": 2, "a": 2}}, + {"insert": 0, "document": {"_id": 3, "a": 3}}, + ], + }, + expected={"ok": 1.0, "nInserted": 3}, + msg="bulkWrite should perform multiple inserts in one command", + ), + CommandTestCase( + "insert_with_specified_id", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 100, "x": "hello"}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a specified _id", + ), + CommandTestCase( + "insert_auto_generated_id", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"x": "no_id"}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with an auto-generated _id", + ), + CommandTestCase( + "insert_into_nonexistent_collection", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "x": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite insert should implicitly create a non-existent collection", + ), + CommandTestCase( + "insert_empty_document", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert an empty document with an auto-generated _id", + ), + CommandTestCase( + "insert_deeply_nested", + command={ + "bulkWrite": 1, + "ops": [ + { + "insert": 0, + "document": { + "_id": 1, + "level0": { + "level1": { + "level2": { + "level3": { + "level4": { + "level5": { + "level6": { + "level7": { + "level8": { + "level9": { + "level10": { + "level11": {"value": "deep"} + } + } + } + } + } + } + } + } + } + } + }, + }, + } + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a deeply nested document", + ), + CommandTestCase( + "insert_long_field_names", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "a" * 200: "value"}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with very long field names", + ), + CommandTestCase( + "insert_dollar_prefixed_fields", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "$dollar": "value"}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with dollar-prefixed field names", + ), + CommandTestCase( + "insert_dot_notation_fields", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "a.b": "value"}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with dot-notation field names", + ), + CommandTestCase( + "insert_into_capped_collection", + target_collection=CappedCollection(size=1048576), + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "x": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert into a capped collection", + ), +] + +BULKWRITE_INSERT_TESTS = BULKWRITE_INSERT_TYPE_TESTS + BULKWRITE_INSERT_CORE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_INSERT_TESTS)) +def test_bulkWrite_core_insert(database_client, collection, test): + """Test bulkWrite core insert operations, data type coverage, and document edge cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py new file mode 100644 index 000000000..1c13fcf6f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py @@ -0,0 +1,281 @@ +"""Tests for bulkWrite core update operations, filter edge cases, and multi-operation handling.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertResult, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +BULKWRITE_UPDATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_update", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 20}}}], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should perform a single update", + ), + CommandTestCase( + "update_set_inc_unset", + docs=[{"_id": 1, "x": 10, "y": 5, "z": 1}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"x": 20}, "$inc": {"y": 1}, "$unset": {"z": ""}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should apply $set, $inc, and $unset operators", + ), + CommandTestCase( + "update_pipeline", + docs=[{"_id": 1, "x": 10, "y": 5}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": 99}}, {"$unset": "y"}], + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should accept an aggregation pipeline", + ), + CommandTestCase( + "update_no_match", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 999}, "updateMods": {"$set": {"x": 20}}}], + }, + expected={"ok": 1.0, "nMatched": 0, "nModified": 0}, + msg="bulkWrite update with a non-matching filter should match nothing", + ), + CommandTestCase( + "update_multi_true", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 99}}, "multi": True} + ], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite update with multi:true should update all matching documents", + ), + CommandTestCase( + "update_multi_false", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 99}}, "multi": False} + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update with multi:false should update only the first match", + ), + CommandTestCase( + "upsert_true_no_match", + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 100}, + "updateMods": {"$set": {"x": 1}}, + "upsert": True, + } + ], + }, + expected={"ok": 1.0, "nUpserted": 1}, + msg="bulkWrite update with upsert:true and no match should insert a document", + ), + CommandTestCase( + "update_nonexistent_collection", + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 2}}}], + }, + expected={"ok": 1.0, "nMatched": 0, "nModified": 0}, + msg="bulkWrite update on a non-existent collection should match nothing", + ), + CommandTestCase( + "update_empty_collection", + docs=[], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 2}}}], + }, + expected={"ok": 1.0, "nMatched": 0, "nModified": 0}, + msg="bulkWrite update on an empty collection should match nothing", + ), + CommandTestCase( + "update_empty_filter_multi", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {}, "updateMods": {"$set": {"y": 1}}, "multi": True}], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite update with an empty filter and multi:true should match all documents", + ), + CommandTestCase( + "update_and_or_filter", + docs=[{"_id": 1, "x": 1, "y": 1}, {"_id": 2, "x": 1, "y": 2}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$and": [{"x": 1}, {"$or": [{"y": 1}]}]}, + "updateMods": {"$set": {"z": 1}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should support $and/$or operators in the filter", + ), + CommandTestCase( + "update_regex_filter", + docs=[{"_id": 1, "name": "Alice"}, {"_id": 2, "name": "Bob"}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"name": {"$regex": "^A"}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should support a regex filter", + ), + CommandTestCase( + "update_expr_filter", + docs=[{"_id": 1, "x": 5}, {"_id": 2, "x": 15}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$gt": ["$x", 10]}}, + "updateMods": {"$set": {"big": True}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should support $expr in the filter", + ), + CommandTestCase( + "large_batch_updates", + docs=[{"_id": i, "x": i} for i in range(100)], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": i}, "updateMods": {"$set": {"x": i + 1}}} + for i in range(100) + ], + }, + expected={"ok": 1.0, "nMatched": 100, "nModified": 100}, + msg="bulkWrite should apply a large batch of update operations", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_UPDATE_TESTS)) +def test_bulkWrite_core_update(database_client, collection, test): + """Test bulkWrite core update operations, filter edge cases, and multi-operation handling.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_sequential_ops_accumulate_on_same_document(collection): + """Test multiple ops on the same document in one bulkWrite apply sequentially (x: 1->2->12).""" + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 2}}}, + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$inc": {"x": 10}}}, + ], + "nsInfo": [{"ns": ns}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "x": 12}]) + + +def test_bulkWrite_replacement_style_update_replaces_document(collection): + """Test a replacement-style updateMods (no $ operators) replaces the whole document body.""" + collection.insert_one({"_id": 1, "x": 10, "y": 20, "z": 30}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"a": 1, "b": 2}}], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "a": 1, "b": 2}], + msg="bulkWrite replacement-style update should replace the body, removing non-_id fields", + ) + + +def test_bulkWrite_upsert_id_in_response(collection): + """Test an upsert carries the upserted _id in cursor.firstBatch[].upserted._id.""" + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 100}, + "updateMods": {"$set": {"x": 1}}, + "upsert": True, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nUpserted": Eq(1), + "cursor.firstBatch.0.upserted._id": Eq(100), + }, + raw_res=True, + msg="bulkWrite upsert should carry the upserted _id in cursor.firstBatch[].upserted._id", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py new file mode 100644 index 000000000..c27de3122 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py @@ -0,0 +1,626 @@ +"""Tests for bulkWrite error and rejection cases.""" + +import uuid + +import pytest +from bson.binary import Binary + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + BSON_OBJECT_TOO_LARGE_ERROR, + COLLECTION_UUID_MISMATCH_ERROR, + COMMAND_NOT_FOUND_ERROR, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + DOCUMENT_VALIDATION_FAILURE_ERROR, + DUPLICATE_KEY_ERROR, + FAILED_TO_PARSE_ERROR, + IMMUTABLE_FIELD_ERROR, + INVALID_BSON_ID_ERROR, + INVALID_LENGTH_ERROR, + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + UPDATE_C_FIELD_REQUIRES_PIPELINE_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Len +from documentdb_tests.framework.target_collection import CustomCollection, ViewCollection + +BULKWRITE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ops_empty_array", + command={"bulkWrite": 1, "ops": []}, + error_code=INVALID_LENGTH_ERROR, + msg="bulkWrite with an empty ops array should fail with InvalidLength", + ), + CommandTestCase( + "ops_field_missing", + command={"bulkWrite": 1}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite without an ops field should fail with MissingField", + ), + CommandTestCase( + "ops_insert_missing_document", + command={"bulkWrite": 1, "ops": [{"insert": 0}]}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite insert op missing document should fail with MissingField", + ), + CommandTestCase( + "ops_update_missing_filter", + command={"bulkWrite": 1, "ops": [{"update": 0, "updateMods": {"$set": {"x": 1}}}]}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite update op missing filter should fail with MissingField", + ), + CommandTestCase( + "ops_delete_missing_filter", + command={"bulkWrite": 1, "ops": [{"delete": 0}]}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite delete op missing filter should fail with MissingField", + ), + CommandTestCase( + "ops_invalid_namespace_index", + command={"bulkWrite": 1, "ops": [{"insert": 5, "document": {"_id": 1}}]}, + error_code=BAD_VALUE_ERROR, + msg="bulkWrite op referencing a non-existent nsInfo index should fail with BadValue", + ), + CommandTestCase( + "ops_negative_namespace_index", + command={"bulkWrite": 1, "ops": [{"insert": -1, "document": {"_id": 1}}]}, + error_code=BAD_VALUE_ERROR, + msg="bulkWrite op with a negative namespace index should fail with BadValue", + ), + CommandTestCase( + "nsInfo_empty_array", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}], "nsInfo": []}, + error_code=BAD_VALUE_ERROR, + msg="bulkWrite with an empty nsInfo array should fail with BadValue", + ), + CommandTestCase( + "nsInfo_invalid_ns_format", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": "no_dot"}], + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="bulkWrite with an invalid namespace format should fail with InvalidNamespace", + ), + CommandTestCase( + "unrecognized_top_level_field", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "unknownField": True, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="bulkWrite with an unrecognized top-level field should fail", + ), + CommandTestCase( + "unrecognized_op_discriminator", + command={ + "bulkWrite": 1, + "ops": [{"replace": 0, "document": {"_id": 1}}], + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="bulkWrite op with an unrecognized discriminator should fail at parse time", + ), +] + +BULKWRITE_OPERATION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "invalid_filter_expression", + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"$badOp": 1}, "updateMods": {"$set": {"x": 1}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite with an invalid query operator should report nErrors:1", + ), + CommandTestCase( + "invalid_update_expression", + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$badOp": {"x": 1}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(FAILED_TO_PARSE_ERROR), + }, + msg="bulkWrite with an invalid update operator should report nErrors:1", + ), + CommandTestCase( + "duplicate_id_insert", + docs=[{"_id": 1}], + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "nErrors": Eq(1), "nInserted": Eq(0)}, + msg="bulkWrite duplicate _id insert should report nErrors:1", + ), + CommandTestCase( + "ordered_true_stops_at_dupkey", + docs=[{"_id": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "ordered": True, + }, + expected={"ok": Eq(1.0), "nErrors": Eq(1), "nInserted": Eq(0)}, + msg="bulkWrite ordered:true should stop at the duplicate key", + ), + CommandTestCase( + "ordered_false_continues_after_dupkey", + docs=[{"_id": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "ordered": False, + }, + expected={"ok": Eq(1.0), "nErrors": Eq(1), "nInserted": Eq(2)}, + msg="bulkWrite ordered:false should continue after the duplicate key", + ), + CommandTestCase( + "insert_unique_index_violation", + indexes=[IndexModel([("x", 1)], unique=True)], + docs=[{"_id": 1, "x": 1}], + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 2, "x": 1}}]}, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DUPLICATE_KEY_ERROR), + }, + msg="bulkWrite insert violating a unique index should report nErrors:1", + ), + CommandTestCase( + "update_causes_unique_index_violation", + indexes=[IndexModel([("x", 1)], unique=True)], + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 2}, "updateMods": {"$set": {"x": 1}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DUPLICATE_KEY_ERROR), + }, + msg="bulkWrite update causing a unique index violation should report nErrors:1", + ), + CommandTestCase( + "update_immutable_field", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"_id": 2}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(IMMUTABLE_FIELD_ERROR), + }, + msg="bulkWrite update of the immutable _id field should report nErrors:1", + ), + CommandTestCase( + "update_on_view", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {}, "updateMods": {"$set": {"x": 2}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR), + }, + msg="bulkWrite update on a view should report nErrors:1", + ), + CommandTestCase( + "delete_on_view", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nDeleted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR), + }, + msg="bulkWrite delete on a view should report nErrors:1", + ), + CommandTestCase( + "schema_validation_rejects_insert", + target_collection=CustomCollection( + options={"validator": {"$jsonSchema": {"required": ["name"]}}} + ), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": False, + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DOCUMENT_VALIDATION_FAILURE_ERROR), + }, + msg="bulkWrite bypassDocumentValidation:false should reject a validator-violating insert", + ), + CommandTestCase( + "arrayFilters_invalid_filter", + docs=[{"_id": 1, "items": [{"x": 1}]}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[elem].x": 99}}, + "arrayFilters": [{"elem.x": {"$badOp": 1}}], + } + ], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite arrayFilters with an invalid operator should report nErrors:1", + ), + CommandTestCase( + "update_hint_nonexistent_index", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"x": 10}, + "updateMods": {"$set": {"x": 20}}, + "hint": "nonexistent_index", + } + ], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite update with a hint on a non-existent index should report nErrors:1", + ), + CommandTestCase( + "delete_hint_nonexistent_index", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"x": 10}, "hint": "nonexistent_index"}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nDeleted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite delete with a hint on a non-existent index should report nErrors:1", + ), + CommandTestCase( + "constants_on_non_pipeline_update", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"x": 20}}, + "constants": {"val": 1}, + } + ], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(UPDATE_C_FIELD_REQUIRES_PIPELINE_ERROR), + }, + msg="bulkWrite constants on a non-pipeline update should report nErrors:1", + ), + CommandTestCase( + "errorsOnly_true_with_error", + docs=[{"_id": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "errorsOnly": True, + "ordered": False, + }, + expected={"ok": Eq(1.0), "nErrors": Eq(1)}, + msg="bulkWrite errorsOnly:true with a failing operation should report nErrors:1", + ), + CommandTestCase( + "array_id_surfaces_as_op_level_error", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": [1, 2]}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(INVALID_BSON_ID_ERROR), + }, + msg="bulkWrite should surface an array _id as an op-level error (code 53), not reject it", + ), + CommandTestCase( + "collection_uuid_mismatch", + docs=[{"_id": 0}], + command=lambda ctx: { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": ctx.namespace, "collectionUUID": Binary(uuid.uuid4().bytes, 4)}], + }, + # Unlike renameCollection (top-level), bulkWrite surfaces this mismatch op-level. + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(COLLECTION_UUID_MISMATCH_ERROR), + }, + msg="bulkWrite should surface a collectionUUID mismatch as an op-level error (code 361)", + ), +] + +BULKWRITE_ERROR_TESTS = BULKWRITE_REJECTION_TESTS + BULKWRITE_OPERATION_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_ERROR_TESTS)) +def test_bulkWrite_errors(database_client, collection, test): + """Test bulkWrite error and rejection cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_bulkWrite_failure_in_one_ns_does_not_block_another(collection): + """Test an unordered bulkWrite failure in one namespace does not block writes to another.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "x": 2}}, # dup key on ns 0 + {"insert": 1, "document": {"_id": 1, "y": 1}}, # succeeds on ns 1 + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + "ordered": False, + }, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "nInserted": Eq(1), "nErrors": Eq(1)}, + raw_res=True, + ) + sibling.drop() + + +def test_bulkWrite_command_name_must_be_first_field(collection): + """Test bulkWrite is rejected with CommandNotFound when bulkWrite is not the first field.""" + ns = f"{collection.database.name}.{collection.name}" + # nsInfo is intentionally placed before bulkWrite so it is read as the command name. + result = execute_admin_command( + collection, + { + "nsInfo": [{"ns": ns}], + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + }, + ) + assertResult(result, error_code=COMMAND_NOT_FOUND_ERROR, raw_res=True) + + +def test_bulkWrite_oversized_document(collection): + """Test an op inserting a document over the 16MB BSON limit reports an op-level error.""" + large_doc = {"_id": 1, "data": "x" * (16 * 1024 * 1024)} + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + {"bulkWrite": 1, "ops": [{"insert": 0, "document": large_doc}], "nsInfo": [{"ns": ns}]}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BSON_OBJECT_TOO_LARGE_ERROR), + }, + raw_res=True, + msg="bulkWrite oversized document should report an op-level BSONObjectTooLarge error", + ) + + +def test_bulkWrite_oversized_document_in_batch(collection): + """Test an oversized doc fails op-level while a sibling op in the unordered batch succeeds.""" + large_doc = {"_id": 1, "data": "x" * (16 * 1024 * 1024)} + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": large_doc}, + {"insert": 0, "document": {"_id": 2, "x": 1}}, + ], + "nsInfo": [{"ns": ns}], + "ordered": False, + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(1), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BSON_OBJECT_TOO_LARGE_ERROR), + }, + raw_res=True, + msg="bulkWrite oversized doc should fail op-level while the sibling op succeeds", + ) + + +def test_bulkWrite_duplicate_key_per_op_cursor_detail(collection): + """Test a dup-key op reports per-op detail (idx/code/keyPattern/keyValue) in firstBatch.""" + collection.insert_one({"_id": 1}) + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + {"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}], "nsInfo": [{"ns": ns}]}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DUPLICATE_KEY_ERROR), + "cursor.firstBatch.0.keyPattern": Eq({"_id": 1}), + "cursor.firstBatch.0.keyValue": Eq({"_id": 1}), + }, + raw_res=True, + msg="bulkWrite dup-key op should report per-op detail (idx/code/keyPattern/keyValue) " + "in firstBatch", + ) + + +def test_bulkWrite_ordered_partial_success_cursor_shape(collection): + """Test ordered:true partial-succeeds, errors at idx 1, and omits the never-run 3rd op.""" + collection.insert_one({"_id": 1}) # pre-existing → second op is a dup + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 2}}, # good + {"insert": 0, "document": {"_id": 1}}, # dup → error at idx 1 + {"insert": 0, "document": {"_id": 3}}, # never runs (ordered:true) + ], + "nsInfo": [{"ns": ns}], + "ordered": True, + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nInserted": Eq(1), + "nErrors": Eq(1), + "cursor.firstBatch": Len(2), # 3rd op absent — never executed + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.1.ok": Eq(0.0), + "cursor.firstBatch.1.idx": Eq(1), + "cursor.firstBatch.1.code": Eq(DUPLICATE_KEY_ERROR), + }, + raw_res=True, + msg="bulkWrite ordered:true should partial-succeed, error at idx 1, and omit the 3rd op", + ) + + +_MAX_WRITE_BATCH_SIZE = 100_000 + + +def test_bulkWrite_exceeds_max_write_batch_size(collection): + """Test exceeding maxWriteBatchSize (100,000 ops) is rejected with InvalidLength.""" + ns = f"{collection.database.name}.{collection.name}" + ops = [{"insert": 0, "document": {}} for _ in range(_MAX_WRITE_BATCH_SIZE + 1)] + result = execute_admin_command( + collection, + {"bulkWrite": 1, "ops": ops, "nsInfo": [{"ns": ns}]}, + ) + assertResult( + result, + error_code=INVALID_LENGTH_ERROR, + raw_res=True, + msg="bulkWrite exceeding maxWriteBatchSize should fail with InvalidLength", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py new file mode 100644 index 000000000..11109622a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py @@ -0,0 +1,228 @@ +"""Tests for bulkWrite let variables, per-statement constants, and constants override behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertResult, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import LET_UNDEFINED_VARIABLE_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +BULKWRITE_LET_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "let_variables_in_filter", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$target"]}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + "let": {"target": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should resolve let variables referenced in filters", + ), + CommandTestCase( + "let_empty_document", + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 2}}}], + "let": {}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should accept an empty let document", + ), + CommandTestCase( + "let_variable_in_delete_filter", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"$expr": {"$eq": ["$x", "$$delTarget"]}}}], + "let": {"delTarget": 10}, + }, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite should resolve let variables referenced in delete filters", + ), + CommandTestCase( + "constants_override_let_in_updateMods", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": "$$val"}}], + "constants": {"val": 99}, + } + ], + "let": {"val": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite constants should take precedence over let for the same variable", + ), + CommandTestCase( + "let_variable_name_collision_with_field", + docs=[{"_id": 1, "x": 10, "target": 999}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$target"]}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + "let": {"target": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite let variable should take precedence over a same-named field in $expr", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_LET_TESTS)) +def test_bulkWrite_let_variables(database_client, collection, test): + """Test bulkWrite let variables, per-statement constants, and constants override behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_constants_override_let_writes_constants_value(collection): + """Test constants takes precedence over let: the constants value 99, not let 10, is written.""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": "$$val"}}], + "constants": {"val": 99}, + } + ], + "nsInfo": [{"ns": ns}], + "let": {"val": 10}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "x": 99}]) + + +def test_bulkWrite_let_variable_resolved_in_pipeline_updateMods(collection): + """Test a let $$var in a pipeline updateMods resolves to its value (read-back x==99).""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": 1}, "updateMods": [{"$set": {"x": "$$newVal"}}]} + ], + "nsInfo": [{"ns": ns}], + "let": {"newVal": 99}, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "x": 99}], + msg="bulkWrite should resolve a let variable in a pipeline updateMods to its value", + ) + + +def test_bulkWrite_constants_resolved_in_pipeline_update(collection): + """Test a per-statement constant in a pipeline update resolves to x==42 (read-back).""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": "$$myConst"}}], + "constants": {"myConst": 42}, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "x": 42}], + msg="bulkWrite should resolve a per-statement constant in a pipeline update to its value", + ) + + +def test_bulkWrite_let_not_resolved_in_modifier_update(collection): + """Test a let $$var is stored literally (not resolved) in a non-pipeline $set update.""" + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": "$$v"}}}], + "nsInfo": [{"ns": ns}], + "let": {"v": 99}, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "x": "$$v"}], + msg="bulkWrite modifier update should store the literal '$$v', not resolve the let var", + ) + + +def test_bulkWrite_undefined_let_variable_op_error(collection): + """Test referencing an undefined let variable yields an op-level error (code 17276).""" + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$missing"]}}, + "updateMods": {"$set": {"hit": True}}, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "cursor.firstBatch.0.code": Eq(LET_UNDEFINED_VARIABLE_ERROR), + }, + raw_res=True, + msg="bulkWrite should report an undefined let variable as op error code 17276", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py new file mode 100644 index 000000000..77d1d3ea5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py @@ -0,0 +1,208 @@ +"""Tests for bulkWrite mixed and multi-namespace operations.""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command + + +def test_bulkWrite_insert_update_delete_mixed(collection): + """Test bulkWrite combines insert, update, and delete in one command.""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 2, "x": 20}}, + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 99}}}, + {"delete": 0, "filter": {"_id": 2}}, + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 1, "nMatched": 1, "nModified": 1, "nDeleted": 1}, + msg="bulkWrite should combine insert, update, and delete in one command", + ) + + +def test_bulkWrite_operations_across_multiple_namespaces(collection): + """Test bulkWrite operates across multiple namespaces listed in the nsInfo array.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "src": "a"}}, + {"insert": 1, "document": {"_id": 1, "src": "b"}}, + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 2}, + msg="bulkWrite should operate across multiple namespaces in the nsInfo array", + ) + sibling.drop() + + +def test_bulkWrite_ops_reference_correct_namespace_by_index(collection): + """Test bulkWrite ops target the namespace selected by their index into nsInfo.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 1, "document": {"_id": 10, "y": 1}}, + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 99}}}, + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 1, "nMatched": 1, "nModified": 1}, + msg="bulkWrite ops should reference the correct namespace by index", + ) + sibling.drop() + + +def test_bulkWrite_interleaved_namespaces(collection): + """Test bulkWrite executes operations interleaved across multiple namespaces.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 1, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 1, "document": {"_id": 2}}, + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 4}, + msg="bulkWrite should execute operations interleaved across namespaces", + ) + sibling.drop() + + +def _run_routing_bulkwrite(collection, sibling): + """Run a 2-namespace bulkWrite with overlapping _ids to catch mis-routing via content.""" + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) # seeds ns0 only + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 2, "tag": "a"}}, # ns0 + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 99}}}, # ns0 + {"insert": 1, "document": {"_id": 1, "tag": "b"}}, # ns1 (same _id as ns0) + {"insert": 1, "document": {"_id": 2, "tag": "b2"}}, # ns1 (same _id as ns0) + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + + +def test_bulkWrite_routes_index0_ops_to_namespace0_read_back(collection): + """Test index-0 ops land only in namespace 0, verified by read-back.""" + sibling = collection.database[f"{collection.name}_b"] + _run_routing_bulkwrite(collection, sibling) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "x": 99}, {"_id": 2, "tag": "a"}], + msg="bulkWrite ops with namespace index 0 should land only in namespace 0", + ) + + +def test_bulkWrite_routes_index1_ops_to_namespace1_read_back(collection): + """Test index-1 ops land only in namespace 1, verified by read-back.""" + sibling = collection.database[f"{collection.name}_b"] + _run_routing_bulkwrite(collection, sibling) + assertSuccess( + execute_command(sibling, {"find": sibling.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "tag": "b"}, {"_id": 2, "tag": "b2"}], + msg="bulkWrite ops with namespace index 1 should land only in namespace 1", + ) + + +def _seed_two_namespaces(collection, sibling): + """Seed ns0 with {_id:1,x:1} and {_id:50}, ns1 with {_id:1,y:1}; return (ns0, ns1).""" + sibling.drop() + collection.insert_many([{"_id": 1, "x": 1}, {"_id": 50}]) + sibling.insert_one({"_id": 1, "y": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + return ns, ns_b + + +def _mixed_cross_ns_ops(): + """Insert+update+delete across two namespaces, with a dup-key failure at idx 2.""" + return [ + {"insert": 0, "document": {"_id": 2, "v": "i0"}}, # ns0 insert — good + {"update": 1, "filter": {"_id": 1}, "updateMods": {"$set": {"y": 99}}}, # ns1 update — good + {"insert": 0, "document": {"_id": 1, "v": "dup"}}, # ns0 insert — dup key, FAILS (idx 2) + {"delete": 0, "filter": {"_id": 50}}, # ns0 delete — good, but after the failure + ] + + +def test_bulkWrite_ordered_true_skips_delete_after_mid_batch_failure(collection): + """Test ordered:true mixed-op/multi-ns batch stops at the failure, skipping the later delete.""" + sibling = collection.database[f"{collection.name}_b"] + ns, ns_b = _seed_two_namespaces(collection, sibling) + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": _mixed_cross_ns_ops(), + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + "ordered": True, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "x": 1}, {"_id": 2, "v": "i0"}, {"_id": 50}], + msg="ordered:true should skip the delete after the mid-batch failure (_id:50 kept)", + ) + + +def test_bulkWrite_ordered_false_runs_delete_after_mid_batch_failure(collection): + """Test ordered:false mixed-op/multi-ns batch runs the later delete despite the failure.""" + sibling = collection.database[f"{collection.name}_b"] + ns, ns_b = _seed_two_namespaces(collection, sibling) + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": _mixed_cross_ns_ops(), + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + "ordered": False, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "x": 1}, {"_id": 2, "v": "i0"}], + msg="ordered:false should run the delete after the mid-batch failure (_id:50 removed)", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py new file mode 100644 index 000000000..819a91b04 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py @@ -0,0 +1,200 @@ +"""Tests for bulkWrite response structure and cursor behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gt, IsType, Len + +BULKWRITE_RESPONSE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "response_contains_cursor_id", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"cursor.id": IsType("long")}, + msg="bulkWrite response should contain a long cursor.id", + ), + CommandTestCase( + "response_contains_cursor_ns", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "cursor.ns": Eq("admin.$cmd.bulkWrite")}, + msg="bulkWrite response cursor.ns should be admin.$cmd.bulkWrite", + ), + CommandTestCase( + "response_contains_firstBatch", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={ + "ok": Eq(1.0), + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.n": Eq(1), + }, + msg="bulkWrite response should contain a cursor.firstBatch array", + ), + CommandTestCase( + "response_nErrors_zero_on_success", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "nErrors": Eq(0)}, + msg="bulkWrite response should contain nErrors:0 on success", + ), + CommandTestCase( + "response_nInserted", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + }, + expected={"ok": Eq(1.0), "nInserted": Eq(2)}, + msg="bulkWrite response should contain nInserted", + ), + CommandTestCase( + "response_nMatched_nModified", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 20}}}], + }, + expected={"ok": Eq(1.0), "nMatched": Eq(1), "nModified": Eq(1)}, + msg="bulkWrite response should contain nMatched and nModified", + ), + CommandTestCase( + "response_matched_but_unmodified", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + # $set to the SAME value: matches the doc but changes nothing. + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 10}}}], + }, + expected={ + "ok": Eq(1.0), + "nMatched": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.n": Eq(1), + "cursor.firstBatch.0.nModified": Eq(0), + }, + msg="bulkWrite no-op update should report nMatched:1/nModified:0 and per-op nModified:0", + ), + CommandTestCase( + "response_nDeleted", + docs=[{"_id": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "nDeleted": Eq(1)}, + msg="bulkWrite response should contain nDeleted", + ), + CommandTestCase( + "errorsOnly_false_returns_all_results", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "errorsOnly": False, + }, + expected={ + "ok": Eq(1.0), + "nInserted": Eq(2), + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.n": Eq(1), + "cursor.firstBatch.1.ok": Eq(1.0), + "cursor.firstBatch.1.idx": Eq(1), + "cursor.firstBatch.1.n": Eq(1), + }, + msg="bulkWrite errorsOnly:false should return all operation results", + ), + CommandTestCase( + "errorsOnly_true_full_success_empty_firstBatch", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "errorsOnly": True, + }, + expected={"ok": Eq(1.0), "nInserted": Eq(2), "nErrors": Eq(0), "cursor.firstBatch": Len(0)}, + msg="bulkWrite errorsOnly:true on full success should return an empty firstBatch", + ), + CommandTestCase( + "cursor_batchSize_1", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "cursor": {"batchSize": 1}, + "errorsOnly": False, + }, + expected={"cursor.firstBatch": Len(1)}, + msg="bulkWrite cursor.batchSize:1 should limit firstBatch to one result", + ), + CommandTestCase( + "cursor_batchSize_zero", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "cursor": {"batchSize": 0}, + "errorsOnly": False, + }, + expected={"cursor.firstBatch": Len(0), "cursor.id": Gt(0)}, + msg="bulkWrite cursor.batchSize:0 should return an empty firstBatch with a live cursor", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_RESPONSE_TESTS)) +def test_bulkWrite_response_structure(database_client, collection, test): + """Test bulkWrite response structure and cursor behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertResult(result, expected=test.build_expected(ctx), msg=test.msg, raw_res=True) + + +def test_bulkWrite_getMore_drains_remaining_results(collection): + """Test a batched bulkWrite cursor (batchSize:1) is drained with getMore into nextBatch.""" + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "nsInfo": [{"ns": ns}], + "errorsOnly": False, + "cursor": {"batchSize": 1}, + }, + ) + cursor_id = result["cursor"]["id"] + more = execute_admin_command(collection, {"getMore": cursor_id, "collection": "$cmd.bulkWrite"}) + assertResult( + more, + expected={ + "ok": Eq(1.0), + "cursor.nextBatch": Len(2), + "cursor.nextBatch.0.idx": Eq(1), + "cursor.nextBatch.1.idx": Eq(2), + }, + raw_res=True, + msg="bulkWrite getMore should drain the remaining op results into nextBatch", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py new file mode 100644 index 000000000..e6e8d5020 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py @@ -0,0 +1,203 @@ +"""Tests for bulkWrite sub-features: arrayFilters, hint, collation, and bypassDocumentValidation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import CustomCollection + +BULKWRITE_SUB_FEATURE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "arrayFilters_modifies_matching_elements", + docs=[{"_id": 1, "items": [{"x": 1}, {"x": 2}, {"x": 3}]}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[elem].x": 99}}, + "arrayFilters": [{"elem.x": {"$gt": 1}}], + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite arrayFilters should modify matching array elements", + ), + CommandTestCase( + "update_with_hint", + indexes=[IndexModel([("x", 1)], name="x_1")], + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"x": 10}, + "updateMods": {"$set": {"x": 20}}, + "hint": "x_1", + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update with a valid hint should succeed", + ), + CommandTestCase( + "delete_with_hint", + indexes=[IndexModel([("x", 1)], name="x_1")], + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"x": 10}, "hint": "x_1"}], + }, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite delete with a valid hint should succeed", + ), + CommandTestCase( + "update_with_collation", + # Comparison semantics are owned by tests/core/collation/; this only checks wiring. + docs=[{"_id": 1, "name": "café"}, {"_id": 2, "name": "cafe"}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"name": "cafe"}, + "updateMods": {"$set": {"matched": True}}, + "collation": {"locale": "en", "strength": 1}, + "multi": True, + } + ], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite should forward a per-op collation to the update (wiring check)", + ), + CommandTestCase( + "bypassDocumentValidation_true", + target_collection=CustomCollection( + options={"validator": {"$jsonSchema": {"required": ["name"]}}} + ), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "x": 1}}], # missing "name" + "bypassDocumentValidation": True, + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite bypassDocumentValidation:true should allow a validator-violating write", + ), + CommandTestCase( + "update_mixed_collation", + docs=[{"_id": 1, "name": "Apple", "v": 1}, {"_id": 2, "name": "apple", "v": 1}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"name": "apple"}, + "updateMods": {"$set": {"v": 2}}, + "collation": {"locale": "en", "strength": 2}, + "multi": True, + }, + { + "update": 0, + "filter": {"name": "Apple"}, + "updateMods": {"$set": {"v": 3}}, + }, + ], + }, + # First op matches both (case-insensitive), second matches only exact "Apple". + expected={"ok": 1.0, "nMatched": 3, "nModified": 3}, + msg="bulkWrite collation on one op should not leak to another op", + ), + CommandTestCase( + "collection_uuid_match", + docs=[{"_id": 0}], + command=lambda ctx: { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": ctx.namespace, "collectionUUID": ctx.uuids[ctx.collection]}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite with a collectionUUID matching the target collection should succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_SUB_FEATURE_TESTS)) +def test_bulkWrite_sub_features(database_client, collection, test): + """Test bulkWrite sub-features: arrayFilters, hint, collation, and bypassDocumentValidation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_arrayFilters_modifies_correct_nested_elements(collection): + """Test a valid arrayFilters + $[identifier] update changes only matching nested elements.""" + collection.insert_one( + {"_id": 1, "items": [{"x": 1, "k": "a"}, {"x": 2, "k": "b"}, {"x": 3, "k": "c"}]} + ) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[elem].x": 99}}, + "arrayFilters": [{"elem.x": {"$gt": 1}}], + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "items": [{"x": 1, "k": "a"}, {"x": 99, "k": "b"}, {"x": 99, "k": "c"}]}], + msg="bulkWrite arrayFilters should modify only the elements matching the identifier " + "predicate and leave the rest unchanged", + ) + + +def test_bulkWrite_collation_affects_arrayFilters_selection(collection): + """Test op-level collation makes arrayFilters match array elements case-insensitively. + + Which array elements arrayFilters changed is only observable via read-back. With a + strength:2 (case-insensitive) collation, the filter {e: "apple"} matches BOTH "Apple" and + "apple"; without collation it would match only the exact "apple". This interaction is not + exercised by the sibling arrayFilters suite. + """ + collection.insert_one({"_id": 1, "items": ["Apple", "apple", "banana"]}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[e]": "X"}}, + "arrayFilters": [{"e": "apple"}], + "collation": {"locale": "en", "strength": 2}, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "items": ["X", "X", "banana"]}], + msg="bulkWrite collation should make arrayFilters match case-insensitively (both Apples)", + ) From d227fe395c0678a58713ede2bdfb4299ef93d0c3 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 2 Jul 2026 15:48:41 -0700 Subject: [PATCH 27/51] Add getDefaultRWConcern command tests (#633) Signed-off-by: Daniel Frankcom --- ...est_getDefaultRWConcern_command_options.py | 229 +++++++++++++++ .../test_getDefaultRWConcern_read_concern.py | 264 ++++++++++++++++++ .../test_getDefaultRWConcern_response.py | 139 +++++++++ .../test_getDefaultRWConcern_write_concern.py | 106 +++++++ documentdb_tests/framework/error_codes.py | 1 + 5 files changed, 739 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_command_options.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_read_concern.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_response.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_write_concern.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_command_options.py b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_command_options.py new file mode 100644 index 000000000..85c8a5e11 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_command_options.py @@ -0,0 +1,229 @@ +"""Tests for getDefaultRWConcern command input acceptance and rejection behavior.""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.binary import UUID_SUBTYPE + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [Null Field Handling]: a null optional field is accepted and treated +# as absent, so it is not echoed back. +GETDEFAULTRWCONCERN_NULL_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_in_memory", + command={"getDefaultRWConcern": 1, "inMemory": None}, + expected={"ok": Eq(1.0), "inMemory": NotExists()}, + msg="getDefaultRWConcern should treat a null inMemory as field-absent and not echo it", + ), + CommandTestCase( + "null_comment", + command={"getDefaultRWConcern": 1, "comment": None}, + expected={"ok": Eq(1.0), "comment": NotExists()}, + msg="getDefaultRWConcern should accept a null comment and not echo it", + ), + CommandTestCase( + "null_all", + command={"getDefaultRWConcern": None, "inMemory": None, "comment": None}, + expected={"ok": Eq(1.0), "inMemory": NotExists(), "comment": NotExists()}, + msg="getDefaultRWConcern should accept null command value, inMemory, and comment together", + ), +] + +# Property [Command Value Behavior]: the command value is ignored and never +# type-validated, so any BSON value is accepted. +GETDEFAULTRWCONCERN_COMMAND_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"command_value_{tid}", + command={"getDefaultRWConcern": val}, + expected={"ok": Eq(1.0)}, + msg=f"getDefaultRWConcern should ignore a {tid} command value and succeed", + ) + for tid, val in [ + ("null", None), + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "hello"), + ("empty_string", ""), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array_single", [1]), + ("object", {"a": 1}), + ] +] + +# Property [inMemory Behavior]: inMemory is echoed back only when set true; +# false or omitted is accepted but not echoed. +GETDEFAULTRWCONCERN_IN_MEMORY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "in_memory_true", + command={"getDefaultRWConcern": 1, "inMemory": True}, + expected={"ok": Eq(1.0), "inMemory": Eq(True)}, + msg="getDefaultRWConcern should accept inMemory true and echo it back as true", + ), + CommandTestCase( + "in_memory_false", + command={"getDefaultRWConcern": 1, "inMemory": False}, + expected={"ok": Eq(1.0), "inMemory": NotExists()}, + msg="getDefaultRWConcern should accept inMemory false and not echo it", + ), + CommandTestCase( + "in_memory_omitted", + command={"getDefaultRWConcern": 1}, + expected={"ok": Eq(1.0), "inMemory": NotExists()}, + msg="getDefaultRWConcern should default omitted inMemory to false and not echo it", + ), +] + +# Property [comment Behavior]: comment accepts any BSON value and is never +# echoed back. +GETDEFAULTRWCONCERN_COMMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command={"getDefaultRWConcern": 1, "comment": val}, + expected={"ok": Eq(1.0), "comment": NotExists()}, + msg=f"getDefaultRWConcern should accept a {tid} comment and not echo it", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array", [1, 2, 3]), + ("object", {"a": 1}), + ] +] + +# Property [Generic Command Options (smoke)]: the command accepts the generic +# command options shared across commands. +GETDEFAULTRWCONCERN_GENERIC_OPTIONS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "generic_max_time_ms", + command={"getDefaultRWConcern": 1, "maxTimeMS": 100}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept a maxTimeMS option", + ), + CommandTestCase( + "generic_api_version", + command={"getDefaultRWConcern": 1, "apiVersion": "1"}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept an apiVersion option", + ), + CommandTestCase( + "generic_read_preference", + command={"getDefaultRWConcern": 1, "$readPreference": {"mode": "secondary"}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept a $readPreference option", + ), + CommandTestCase( + "generic_lsid", + command={"getDefaultRWConcern": 1, "lsid": {"id": Binary(b"\x01" * 16, UUID_SUBTYPE)}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept an lsid session option", + ), +] + +# Property [inMemory Type Errors]: inMemory is strictly typed as bool, so every +# non-bool, non-null value is rejected. +GETDEFAULTRWCONCERN_IN_MEMORY_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"in_memory_type_{tid}", + command={"getDefaultRWConcern": 1, "inMemory": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} inMemory value as a non-bool", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("string", "x"), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array", [1]), + ("object", {"a": 1}), + ] +] + +# Property [Syntax Validation Errors]: an unknown top-level command field is +# rejected. +GETDEFAULTRWCONCERN_SYNTAX_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field", + command={"getDefaultRWConcern": 1, "unknownField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="getDefaultRWConcern should reject an unknown command field", + ), +] + +GETDEFAULTRWCONCERN_COMMAND_OPTION_TESTS: list[CommandTestCase] = ( + GETDEFAULTRWCONCERN_NULL_FIELD_TESTS + + GETDEFAULTRWCONCERN_COMMAND_VALUE_TESTS + + GETDEFAULTRWCONCERN_IN_MEMORY_TESTS + + GETDEFAULTRWCONCERN_COMMENT_TESTS + + GETDEFAULTRWCONCERN_GENERIC_OPTIONS_TESTS + + GETDEFAULTRWCONCERN_IN_MEMORY_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_SYNTAX_ERROR_TESTS +) + + +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.parametrize("test", pytest_params(GETDEFAULTRWCONCERN_COMMAND_OPTION_TESTS)) +def test_getDefaultRWConcern_command_options(collection, test): + """Test getDefaultRWConcern command value and option acceptance and rejection behavior.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_read_concern.py b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_read_concern.py new file mode 100644 index 000000000..5833eb3c0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_read_concern.py @@ -0,0 +1,264 @@ +"""Tests for getDefaultRWConcern command input acceptance and rejection behavior.""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [readConcern Acceptance]: the command accepts a readConcern that +# resolves to the supported local level. +GETDEFAULTRWCONCERN_READ_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_local", + command={"getDefaultRWConcern": 1, "readConcern": {"level": "local"}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept a readConcern with level local", + ), + CommandTestCase( + "read_concern_empty", + command={"getDefaultRWConcern": 1, "readConcern": {}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept an empty readConcern object", + ), + CommandTestCase( + "read_concern_level_null", + command={"getDefaultRWConcern": 1, "readConcern": {"level": None}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should treat a null readConcern level as the default and succeed", + ), + CommandTestCase( + "read_concern_after_cluster_time", + command={"getDefaultRWConcern": 1, "readConcern": {"afterClusterTime": Timestamp(1, 1)}}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should accept a readConcern with afterClusterTime", + ), +] + +# Property [readConcern Type Errors]: a non-object, non-null readConcern is +# rejected by the object type check. +GETDEFAULTRWCONCERN_READ_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_type_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} readConcern as a non-object", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "local"), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array", [1]), + ] +] + +# Property [readConcern level Type Errors]: a non-string, non-null readConcern +# level is rejected by the type check. +GETDEFAULTRWCONCERN_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_level_type_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": {"level": val}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} readConcern level as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array", ["local"]), + ("object", {"a": 1}), + ] +] + +# Property [readConcern level Value Errors]: a string readConcern level that is +# not a recognized value is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_LEVEL_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_level_value_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": {"level": val}}, + error_code=BAD_VALUE_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} readConcern level value", + ) + for tid, val in [ + ("empty", ""), + ("unknown", "bogus"), + ("wrong_case", "LOCAL"), + ] +] + +# Property [Unsupported readConcern Levels]: recognized read concern levels +# other than local are rejected as unsupported. +GETDEFAULTRWCONCERN_READ_CONCERN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_{level}", + command={"getDefaultRWConcern": 1, "readConcern": {"level": level}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"getDefaultRWConcern should reject the {level} read concern level", + ) + for level in ["available", "majority", "linearizable", "snapshot"] +] + +# Property [readConcern atClusterTime]: atClusterTime is only valid with a +# snapshot level, so supplying it without one is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_AT_CLUSTER_TIME_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_at_cluster_time_without_snapshot", + command={"getDefaultRWConcern": 1, "readConcern": {"atClusterTime": Timestamp(1, 1)}}, + error_code=INVALID_OPTIONS_ERROR, + msg="getDefaultRWConcern should reject atClusterTime without a snapshot level", + ), +] + +# Property [readConcern afterClusterTime Type Errors]: afterClusterTime is +# strictly typed as a timestamp, so every non-timestamp value is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_AFTER_CLUSTER_TIME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_after_cluster_time_type_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": {"afterClusterTime": val}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} afterClusterTime as a non-timestamp", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "x"), + ("null", None), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array", [1]), + ("object", {"a": 1}), + ] +] + +# Property [readConcern afterClusterTime Value Errors]: a zero-valued (null) +# timestamp is a valid timestamp type but not a usable cluster time, so +# afterClusterTime rejects it. +GETDEFAULTRWCONCERN_READ_CONCERN_AFTER_CLUSTER_TIME_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_after_cluster_time_null_timestamp", + command={"getDefaultRWConcern": 1, "readConcern": {"afterClusterTime": Timestamp(0, 0)}}, + error_code=INVALID_OPTIONS_ERROR, + msg="getDefaultRWConcern should reject a null timestamp afterClusterTime", + ), +] + +# Property [readConcern atClusterTime Type Errors]: atClusterTime is strictly +# typed as a timestamp, so every non-timestamp value is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_AT_CLUSTER_TIME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"read_concern_at_cluster_time_type_{tid}", + command={"getDefaultRWConcern": 1, "readConcern": {"atClusterTime": val}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} atClusterTime as a non-timestamp", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "x"), + ("null", None), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array", [1]), + ("object", {"a": 1}), + ] +] + +# Property [readConcern Sub-Field Validation]: an unknown sub-field inside the +# readConcern document is rejected. +GETDEFAULTRWCONCERN_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "read_concern_unknown_subfield", + command={"getDefaultRWConcern": 1, "readConcern": {"level": "local", "unknownField": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="getDefaultRWConcern should reject an unknown readConcern sub-field", + ), +] + +GETDEFAULTRWCONCERN_READ_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + GETDEFAULTRWCONCERN_READ_CONCERN_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_LEVEL_VALUE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_AT_CLUSTER_TIME_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_AFTER_CLUSTER_TIME_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_AFTER_CLUSTER_TIME_VALUE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_AT_CLUSTER_TIME_TYPE_ERROR_TESTS + + GETDEFAULTRWCONCERN_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.parametrize("test", pytest_params(GETDEFAULTRWCONCERN_READ_CONCERN_ALL_TESTS)) +def test_getDefaultRWConcern_read_concern(collection, test): + """Test getDefaultRWConcern readConcern acceptance and rejection behavior.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_response.py b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_response.py new file mode 100644 index 000000000..2c50a4877 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_response.py @@ -0,0 +1,139 @@ +"""Tests for getDefaultRWConcern command input acceptance and rejection behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + NOT_SUPPORTED_ON_STANDALONE_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType + +# Property [Output Schema and Return Shape]: a successful response always +# returns the full defaults schema, for both an on-disk and an in-memory read. +GETDEFAULTRWCONCERN_OUTPUT_SCHEMA_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "output_schema", + command={"getDefaultRWConcern": 1}, + expected={ + "ok": Eq(1.0), + "defaultReadConcern": IsType("object"), + "defaultWriteConcern": IsType("object"), + "defaultReadConcernSource": IsType("string"), + "defaultWriteConcernSource": IsType("string"), + "localUpdateWallClockTime": IsType("date"), + }, + msg="getDefaultRWConcern should always return the read/write concern " + "defaults, their sources, and localUpdateWallClockTime", + ), + CommandTestCase( + "output_schema_in_memory", + command={"getDefaultRWConcern": 1, "inMemory": True}, + expected={ + "ok": Eq(1.0), + "inMemory": Eq(True), + "defaultReadConcern": IsType("object"), + "defaultWriteConcern": IsType("object"), + "defaultReadConcernSource": IsType("string"), + "defaultWriteConcernSource": IsType("string"), + "localUpdateWallClockTime": IsType("date"), + }, + msg="getDefaultRWConcern should return the full defaults schema for an " + "in-memory read and echo inMemory true", + ), +] + + +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.parametrize("test", pytest_params(GETDEFAULTRWCONCERN_OUTPUT_SCHEMA_TESTS)) +def test_getDefaultRWConcern_output_schema(collection, test): + """Test getDefaultRWConcern always returns the full defaults schema.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Topology Errors]: on a standalone node the command is unsupported +# and is rejected. +@pytest.mark.requires(cluster_admin=False) +def test_getDefaultRWConcern_topology_error(collection): + """Test getDefaultRWConcern is unsupported on standalone nodes.""" + result = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + assertResult( + result, + error_code=NOT_SUPPORTED_ON_STANDALONE_ERROR, + msg="getDefaultRWConcern should be unsupported on a standalone node", + raw_res=True, + ) + + +# Property [Default Concern Source Semantics]: setting a default flips the +# corresponding source from implicit to global and surfaces the update +# timestamps. This mutates global state irreversibly (the default write concern +# cannot be unset), so it runs no_parallel. +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.no_parallel +def test_getDefaultRWConcern_source_global_after_set(collection): + """Test getDefaultRWConcern reports global sources once defaults are set.""" + execute_admin_command( + collection, + { + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "defaultWriteConcern": {"w": "majority", "wtimeout": 0}, + }, + ) + result = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "defaultReadConcernSource": Eq("global"), + "defaultWriteConcernSource": Eq("global"), + "updateOpTime": IsType("timestamp"), + "updateWallClockTime": IsType("date"), + }, + msg="getDefaultRWConcern should report global sources and present update " + "timestamps once defaults are set", + raw_res=True, + ) + + +# Property [Default Concern Source Semantics]: unsetting the default read concern +# reverts its source to implicit, while the write concern source stays global +# because it cannot be unset. This mutates global state irreversibly, so it runs +# no_parallel. +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.no_parallel +def test_getDefaultRWConcern_read_source_reverts_on_unset(collection): + """Test getDefaultRWConcern reverts the read source to implicit on unset.""" + execute_admin_command( + collection, + { + "setDefaultRWConcern": 1, + "defaultReadConcern": {"level": "local"}, + "defaultWriteConcern": {"w": "majority", "wtimeout": 0}, + }, + ) + execute_admin_command(collection, {"setDefaultRWConcern": 1, "defaultReadConcern": {}}) + result = execute_admin_command(collection, {"getDefaultRWConcern": 1}) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "defaultReadConcernSource": Eq("implicit"), + "defaultWriteConcernSource": Eq("global"), + }, + msg="getDefaultRWConcern should revert the read concern source to implicit " + "after unset while the write concern source remains global", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_write_concern.py b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_write_concern.py new file mode 100644 index 000000000..648d6f06c --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getDefaultRWConcern/test_getDefaultRWConcern_write_concern.py @@ -0,0 +1,106 @@ +"""Tests for getDefaultRWConcern command input acceptance and rejection behavior.""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [writeConcern Acceptance]: a null writeConcern is treated as absent +# and accepted. +GETDEFAULTRWCONCERN_WRITE_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "write_concern_null", + command={"getDefaultRWConcern": 1, "writeConcern": None}, + expected={"ok": Eq(1.0)}, + msg="getDefaultRWConcern should treat a null writeConcern as field-absent and succeed", + ), +] + +# Property [Unsupported writeConcern]: writeConcern is not supported, so any +# writeConcern object is rejected. +GETDEFAULTRWCONCERN_WRITE_CONCERN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "write_concern_non_empty", + command={"getDefaultRWConcern": 1, "writeConcern": {"w": "majority"}}, + error_code=INVALID_OPTIONS_ERROR, + msg="getDefaultRWConcern should reject a non-empty writeConcern object as unsupported", + ), + CommandTestCase( + "write_concern_empty", + command={"getDefaultRWConcern": 1, "writeConcern": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="getDefaultRWConcern should reject an empty writeConcern object as unsupported", + ), +] + +# Property [writeConcern Type Errors]: a non-object writeConcern is rejected by +# the object type check. +GETDEFAULTRWCONCERN_WRITE_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"write_concern_type_{tid}", + command={"getDefaultRWConcern": 1, "writeConcern": val}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getDefaultRWConcern should reject a {tid} writeConcern as a non-object", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(2)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("string", "majority"), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array", [1]), + ] +] + +GETDEFAULTRWCONCERN_WRITE_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + GETDEFAULTRWCONCERN_WRITE_CONCERN_TESTS + + GETDEFAULTRWCONCERN_WRITE_CONCERN_ERROR_TESTS + + GETDEFAULTRWCONCERN_WRITE_CONCERN_TYPE_ERROR_TESTS +) + + +@pytest.mark.requires(cluster_admin=True) +@pytest.mark.parametrize("test", pytest_params(GETDEFAULTRWCONCERN_WRITE_CONCERN_ALL_TESTS)) +def test_getDefaultRWConcern_write_concern(collection, test): + """Test getDefaultRWConcern writeConcern acceptance and rejection behavior.""" + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index da1ad5976..6353c21e2 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -404,6 +404,7 @@ PROJECT_EMPTY_SUB_PROJECTION_ERROR = 51270 PROJECT_EMPTY_SPEC_ERROR = 51272 MERGE_LET_RESERVED_NEW_ERROR = 51273 +NOT_SUPPORTED_ON_STANDALONE_ERROR = 51300 REPLACE_MISSING_REPLACEMENT_ERROR = 51747 REPLACE_MISSING_FIND_ERROR = 51748 REPLACE_MISSING_INPUT_ERROR = 51749 From 3023aec7bae0011c8d449c0a1bd4727930ed0521 Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Thu, 2 Jul 2026 15:58:41 -0700 Subject: [PATCH 28/51] Add admin command tests for getParameter (#634) Signed-off-by: Victor [C] Tsang --- .../commands/getParameter/__init__.py | 0 .../test_getParameter_bson_type_validation.py | 143 +++++++ .../test_getParameter_core_behavior.py | 348 ++++++++++++++++++ .../getParameter/test_getParameter_errors.py | 169 +++++++++ 4 files changed, 660 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getParameter/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_errors.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_bson_type_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_bson_type_validation.py new file mode 100644 index 000000000..1e39a32e6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_bson_type_validation.py @@ -0,0 +1,143 @@ +"""Tests for getParameter BSON type validation.""" + +import pytest + +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.property_checks import Eq, IsType, NotExists + +pytestmark = pytest.mark.admin + + +GET_PARAMETER_VALUE_PARAMS = [ + BsonTypeTestCase( + id="getParameter_value", + msg="getParameter should accept all BSON types for the command field value", + keyword="getParameter", + valid_types=list(BsonType), + valid_inputs={BsonType.OBJECT: {"showDetails": True}}, + ), +] + +COMMENT_PARAMS = [ + BsonTypeTestCase( + id="comment", + msg="getParameter should accept all BSON types for the comment field", + keyword="comment", + valid_types=list(BsonType), + ), +] + +SET_AT_PARAMS = [ + BsonTypeTestCase( + id="setAt", + msg="setAt should accept string and null", + keyword="setAt", + valid_types=[BsonType.STRING, BsonType.NULL], + requires={"allParameters": True}, + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.STRING: "runtime"}, + ), +] + +BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="showDetails", + msg="showDetails should accept bool, numeric, and null", + keyword="showDetails", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.DOUBLE, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, + ], + requires={"logLevel": 1}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="allParameters", + msg="allParameters should accept bool, numeric, and null", + keyword="allParameters", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.DOUBLE, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + + +GET_PARAMETER_VALUE_ACCEPTANCE = generate_bson_acceptance_test_cases(GET_PARAMETER_VALUE_PARAMS) + +COMMENT_ACCEPTANCE = generate_bson_acceptance_test_cases(COMMENT_PARAMS) + +SETAT_ACCEPTANCE = generate_bson_acceptance_test_cases(SET_AT_PARAMS) + +# GET_PARAMETER_VALUE_PARAMS and COMMENT_PARAMS accept all BSON types, so no rejections. +ALL_REJECTIONS = generate_bson_rejection_test_cases(SET_AT_PARAMS + BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", GET_PARAMETER_VALUE_ACCEPTANCE) +def test_getParameter_value_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test getParameter command-field accepts all BSON types.""" + expected_type = "object" if isinstance(sample_value, dict) else "int" + result = execute_admin_command(collection, {"getParameter": sample_value, "logLevel": 1}) + assertProperties( + result, + {"ok": Eq(1.0), "logLevel": IsType(expected_type)}, + msg=f"getParameter should accept {bson_type.value}", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", COMMENT_ACCEPTANCE) +def test_getParameter_comment_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test comment field accepts all BSON types.""" + result = execute_admin_command( + collection, {"getParameter": 1, "logLevel": 1, "comment": sample_value} + ) + assertProperties( + result, + {"ok": Eq(1.0), "logLevel": IsType("int"), "comment": NotExists()}, + msg=f"comment should accept {bson_type.value}", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SETAT_ACCEPTANCE) +def test_getParameter_setAt_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test setAt field accepts string and null.""" + result = execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": sample_value}} + ) + assertProperties( + result, {"ok": Eq(1.0)}, msg=f"setAt should accept {bson_type.value}", raw_res=True + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ALL_REJECTIONS) +def test_getParameter_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test document-form fields reject invalid BSON types.""" + from documentdb_tests.framework.assertions import assertFailureCode + + command = {"getParameter": {spec.keyword: sample_value}} + if spec.requires: + command["getParameter"].update(spec.requires) + command.update({k: v for k, v in spec.requires.items() if k not in command["getParameter"]}) + result = execute_admin_command(collection, command) + assertFailureCode( + result, spec.default_error_code, msg=f"{spec.keyword} should reject {bson_type.value}" + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_core_behavior.py new file mode 100644 index 000000000..8cec9a924 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_core_behavior.py @@ -0,0 +1,348 @@ +"""Tests for getParameter command core behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccess +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, NotExists + +pytestmark = pytest.mark.admin + + +SINGLE_PARAM_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "int_42_with_named_param", + command={"getParameter": 42, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists()}, + msg="Should return parameter value with any integer", + ), + DiagnosticTestCase( + "int_0_with_named_param", + command={"getParameter": 0, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists()}, + msg="Should return parameter value with integer 0 (value is ignored)", + ), + DiagnosticTestCase( + "negative_int_with_named_param", + command={"getParameter": -1, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists()}, + msg="Should return parameter value with negative integer", + ), + DiagnosticTestCase( + "multiple_named_params", + command={"getParameter": 1, "logLevel": 1, "quiet": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists(), "quiet": Exists()}, + msg="Should return all named parameters requested in one command", + ), + DiagnosticTestCase( + "empty_doc_with_named_param", + command={"getParameter": {}, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": Exists()}, + msg="Empty document with a named parameter should be accepted (behaves like int form)", + ), +] + + +SHOW_DETAILS_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "showDetails_true_returns_detail_fields", + command={"getParameter": {"showDetails": True}, "logLevel": 1}, + checks={ + "ok": Eq(1.0), + "logLevel": Exists(), + "logLevel.value": Exists(), + "logLevel.settableAtRuntime": Eq(True), + "logLevel.settableAtStartup": IsType("bool"), + }, + msg="showDetails:true should return value, settableAtRuntime, settableAtStartup", + ), + DiagnosticTestCase( + "showDetails_true_allParameters", + command={"getParameter": {"showDetails": True, "allParameters": True}}, + checks={ + "ok": Eq(1.0), + "logLevel.value": Exists(), + "logLevel.settableAtRuntime": IsType("bool"), + "logLevel.settableAtStartup": IsType("bool"), + }, + msg="showDetails with allParameters should return details for all", + ), +] + + +SET_AT_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "setAt_startup", + command={"getParameter": {"allParameters": True, "setAt": "startup"}}, + checks={"ok": Eq(1.0)}, + msg="setAt:'startup' with allParameters should succeed", + ), + DiagnosticTestCase( + "setAt_runtime", + command={"getParameter": {"allParameters": True, "setAt": "runtime"}}, + checks={"ok": Eq(1.0)}, + msg="setAt:'runtime' with allParameters should succeed", + ), +] + + +PARAM_DATA_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "param_with_int_value", + command={"getParameter": 1, "logLevel": 1}, + checks={"ok": Eq(1.0), "logLevel": IsType("int")}, + msg="Should retrieve parameter with integer value", + ), + DiagnosticTestCase( + "param_with_bool_value", + command={"getParameter": 1, "allowDiskUseByDefault": 1}, + checks={"ok": Eq(1.0), "allowDiskUseByDefault": IsType("bool")}, + msg="Should retrieve parameter with boolean value", + ), + DiagnosticTestCase( + "param_with_string_value", + command={"getParameter": 1, "ShardingTaskExecutorPoolReplicaSetMatching": 1}, + checks={"ok": Eq(1.0), "ShardingTaskExecutorPoolReplicaSetMatching": IsType("string")}, + msg="Should retrieve parameter with string value", + ), + DiagnosticTestCase( + "param_with_document_value", + command={"getParameter": 1, "featureCompatibilityVersion": 1}, + checks={"ok": Eq(1.0), "featureCompatibilityVersion": IsType("object")}, + msg="Should retrieve parameter with document value", + ), + DiagnosticTestCase( + "param_with_array_value", + command={"getParameter": 1, "authenticationMechanisms": 1}, + checks={"ok": Eq(1.0), "authenticationMechanisms": IsType("array")}, + msg="Should retrieve parameter with array value", + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params(SINGLE_PARAM_TESTS + SHOW_DETAILS_TESTS + SET_AT_TESTS + PARAM_DATA_TYPE_TESTS), +) +def test_getParameter_success_cases(collection, test): + """Test getParameter success cases: retrieval, showDetails, setAt, and value types.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_getParameter_allParameters_true(collection): + """Test getParameter with {allParameters: true} returns all parameters.""" + result = execute_admin_command(collection, {"getParameter": {"allParameters": True}}) + assertProperties( + result, + {"ok": Eq(1.0), "logLevel": Exists()}, + msg="Should return ok:1 and parameters", + raw_res=True, + ) + + +def test_getParameter_star_and_allParameters_consistent(collection): + """Test all parameters returned by '*' are also returned by {allParameters: true}.""" + star_result = execute_admin_command(collection, {"getParameter": "*"}) + all_result = execute_admin_command(collection, {"getParameter": {"allParameters": True}}) + star_keys = set(star_result.keys()) - {"ok"} + all_keys = set(all_result.keys()) - {"ok"} + missing = star_keys - all_keys + assertSuccess( + {"ok": 1.0, "missing": []}, + {"ok": 1.0, "missing": list(missing)}, + raw_res=True, + msg="Keys from '*' should all appear in allParameters", + ) + + +def test_getParameter_showDetails_false(collection): + """Test {showDetails: false} returns parameter value without detail fields.""" + result = execute_admin_command( + collection, {"getParameter": {"showDetails": False}, "logLevel": 1} + ) + assertProperties( + result, + { + "ok": Eq(1.0), + "logLevel": Exists(), + "logLevel.value": NotExists(), + "logLevel.settableAtRuntime": NotExists(), + "logLevel.settableAtStartup": NotExists(), + }, + msg="showDetails:false should return the plain value without detail fields", + raw_res=True, + ) + + +def test_getParameter_setAt_without_allParameters_succeeds(collection): + """Test setAt without allParameters succeeds (setAt is ignored for single param).""" + result = execute_admin_command( + collection, {"getParameter": {"setAt": "startup"}, "logLevel": 1} + ) + assertProperties( + result, + {"ok": Eq(1.0), "logLevel": Exists()}, + msg="setAt without allParameters should succeed", + raw_res=True, + ) + + +def test_getParameter_star_response_has_multiple_params(collection): + """Test '*' response contains multiple parameter fields.""" + result = execute_admin_command(collection, {"getParameter": "*"}) + param_keys = set(result.keys()) - {"ok"} + assertSuccess( + {"ok": 1.0, "has_logLevel": True, "has_multiple_params": True}, + { + "ok": 1.0, + "has_logLevel": "logLevel" in param_keys, + "has_multiple_params": len(param_keys) > 1, + }, + raw_res=True, + msg=f"Star should return multiple params including logLevel, got {len(param_keys)}", + ) + + +def test_getParameter_single_matches_star(collection): + """Test parameter value from single retrieval matches value in '*' response.""" + single = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + star = execute_admin_command(collection, {"getParameter": "*"}) + assertSuccess( + {"ok": 1.0, "logLevel": single["logLevel"]}, + {"ok": 1.0, "logLevel": star["logLevel"]}, + raw_res=True, + msg="Single retrieval should match star value", + ) + + +@pytest.mark.no_parallel +def test_getParameter_reflects_setParameter_change(collection): + """Test getParameter returns updated value after setParameter modifies a runtime parameter.""" + try: + execute_admin_command(collection, {"setParameter": 1, "logLevel": 2}) + result = execute_admin_command(collection, {"getParameter": 1, "logLevel": 1}) + assertProperties( + result, {"logLevel": Eq(2)}, msg="Should reflect setParameter change", raw_res=True + ) + finally: + execute_admin_command(collection, {"setParameter": 1, "logLevel": 0}) + + +def test_getParameter_known_startup_only_param(collection): + """Test retrieval of a known startup-only parameter (KeysRotationIntervalSec).""" + result = execute_admin_command( + collection, {"getParameter": {"showDetails": True}, "KeysRotationIntervalSec": 1} + ) + assertProperties( + result, + { + "KeysRotationIntervalSec.settableAtRuntime": Eq(False), + "KeysRotationIntervalSec.settableAtStartup": Eq(True), + }, + msg="Startup-only param should have settableAtRuntime:false", + raw_res=True, + ) + + +def test_getParameter_fully_immutable_param(collection): + """Test a fully-immutable parameter has both settability flags false.""" + result = execute_admin_command( + collection, {"getParameter": {"showDetails": True}, "authSchemaVersion": 1} + ) + assertProperties( + result, + { + "authSchemaVersion.settableAtRuntime": Eq(False), + "authSchemaVersion.settableAtStartup": Eq(False), + }, + msg="Fully-immutable param should have both settability flags false", + raw_res=True, + ) + + +def test_getParameter_setAt_filters_result_count(collection): + """Test setAt actually narrows the result set.""" + all_keys = set(execute_admin_command(collection, {"getParameter": {"allParameters": True}})) + runtime_keys = set( + execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "runtime"}} + ) + ) + startup_keys = set( + execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "startup"}} + ) + ) + n_all = len(all_keys - {"ok"}) + assertSuccess( + {"ok": 1.0, "runtime_filtered": True, "startup_filtered": True}, + { + "ok": 1.0, + "runtime_filtered": len(runtime_keys - {"ok"}) < n_all, + "startup_filtered": len(startup_keys - {"ok"}) < n_all, + }, + raw_res=True, + msg="setAt:'runtime' and setAt:'startup' should each return fewer params than unfiltered", + ) + + +def test_getParameter_setAt_buckets_startup_only_param(collection): + """Test setAt routes a startup-only param to the correct bucket.""" + runtime_keys = set( + execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "runtime"}} + ) + ) + startup_keys = set( + execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "startup"}} + ) + ) + assertSuccess( + {"ok": 1.0, "in_runtime": False, "in_startup": True}, + { + "ok": 1.0, + "in_runtime": "KeysRotationIntervalSec" in runtime_keys, + "in_startup": "KeysRotationIntervalSec" in startup_keys, + }, + raw_res=True, + msg="Startup-only param should appear only under setAt:'startup'", + ) + + +def test_getParameter_allParameters_with_named_param_returns_all(collection): + """Test {allParameters: true} with a named param returns the same keys as allParameters.""" + all_only = execute_admin_command(collection, {"getParameter": {"allParameters": True}}) + with_named = execute_admin_command( + collection, {"getParameter": {"allParameters": True}, "logLevel": 1} + ) + all_only_keys = set(all_only.keys()) - {"ok"} + with_named_keys = set(with_named.keys()) - {"ok"} + assertSuccess( + {"key_diff": []}, + {"key_diff": sorted(all_only_keys ^ with_named_keys)}, + raw_res=True, + msg="allParameters with a named param should return the same keys as allParameters alone", + ) + + +def test_getParameter_allParameters_with_named_param_not_filtered(collection): + """Test {allParameters: true} with a named param is not narrowed to just that param.""" + result = execute_admin_command( + collection, {"getParameter": {"allParameters": True}, "logLevel": 1} + ) + param_keys = set(result.keys()) - {"ok"} + assertSuccess( + {"has_logLevel": True, "has_other_params": True}, + { + "has_logLevel": "logLevel" in param_keys, + "has_other_params": len(param_keys) > 1, + }, + raw_res=True, + msg="allParameters with a named param should still return all params, not just logLevel", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_errors.py new file mode 100644 index 000000000..fd2692375 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getParameter/test_getParameter_errors.py @@ -0,0 +1,169 @@ +"""Tests for getParameter command error cases.""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + COMMAND_NOT_FOUND_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNAUTHORIZED_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +PARAM_NAME_ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "empty_string_param_name", + command={"getParameter": 1, "": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="Should reject empty string parameter name", + ), + DiagnosticTestCase( + "special_chars_param_name", + command={"getParameter": 1, "!@#$%": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="Should reject parameter name with special characters", + ), + DiagnosticTestCase( + "very_long_param_name", + command={"getParameter": 1, "a" * 1000: 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="Should reject very long parameter name", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PARAM_NAME_ERROR_TESTS)) +def test_getParameter_param_name_errors(collection, test): + """Test getParameter rejects invalid parameter names.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +SUBFIELD_TYPE_ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "showDetails_non_bool", + command={"getParameter": {"showDetails": "yes"}, "logLevel": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject non-boolean showDetails", + ), + DiagnosticTestCase( + "allParameters_non_bool", + command={"getParameter": {"allParameters": "yes"}}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject non-boolean allParameters", + ), + DiagnosticTestCase( + "setAt_non_string", + command={"getParameter": {"allParameters": True, "setAt": 123}}, + error_code=TYPE_MISMATCH_ERROR, + msg="Should reject non-string setAt", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SUBFIELD_TYPE_ERROR_TESTS)) +def test_getParameter_subfield_type_errors(collection, test): + """Test getParameter rejects wrong BSON types for document-form sub-fields.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +SELECTOR_ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "arbitrary_string_not_star", + command={"getParameter": "foo"}, + error_code=INVALID_OPTIONS_ERROR, + msg="Non-'*' string value should be rejected as a parameter name", + ), + DiagnosticTestCase( + "int_form_no_param_name", + command={"getParameter": 1}, + error_code=INVALID_OPTIONS_ERROR, + msg="Integer form without a named parameter should fail", + ), + DiagnosticTestCase( + "empty_doc_no_param_name", + command={"getParameter": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="Empty document without a named parameter should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SELECTOR_ERROR_TESTS)) +def test_getParameter_selector_errors(collection, test): + """Test getParameter rejects invalid or missing parameter selectors.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_getParameter_on_non_admin_db_fails(collection): + """Test getParameter run on a non-admin database fails with Unauthorized.""" + result = execute_command(collection, {"getParameter": 1, "logLevel": 1}) + assertFailureCode(result, UNAUTHORIZED_ERROR, msg="Non-admin DB should fail") + + +def test_getParameter_param_name_case_sensitive(collection): + """Test that parameter names are case-sensitive.""" + result = execute_admin_command(collection, {"getParameter": 1, "LOGLEVEL": 1}) + assertFailureCode( + result, INVALID_OPTIONS_ERROR, msg="Case-mismatched parameter name should fail" + ) + + +def test_getParameter_unrecognized_field_treated_as_param(collection): + """Test an unknown top-level field is treated as a non-existent parameter name.""" + result = execute_admin_command(collection, {"getParameter": 1, "unknownField": 1}) + assertFailureCode( + result, + INVALID_OPTIONS_ERROR, + msg="Unknown field alone treated as non-existent parameter", + ) + + +def test_getParameter_doc_form_unrecognized_field_fails(collection): + """Test the document form with unrecognized fields fails.""" + result = execute_admin_command( + collection, {"getParameter": {"unknownField": True}, "logLevel": 1} + ) + assertFailureCode( + result, UNRECOGNIZED_COMMAND_FIELD_ERROR, msg="Unrecognized doc field should fail" + ) + + +def test_getParameter_no_getParameter_field_fails(collection): + """Test a command without the getParameter field fails with CommandNotFound.""" + result = execute_admin_command(collection, {"logLevel": 1}) + assertFailureCode(result, COMMAND_NOT_FOUND_ERROR, msg="Missing getParameter field should fail") + + +def test_getParameter_command_field_not_first_fails(collection): + """Test the command fails when getParameter is not the first field.""" + result = execute_admin_command(collection, {"logLevel": 1, "getParameter": 1}) + assertFailureCode(result, COMMAND_NOT_FOUND_ERROR, msg="Command field not first should fail") + + +def test_getParameter_setAt_invalid_value(collection): + """Test setAt with an invalid value fails with BadValue.""" + result = execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": "invalid"}} + ) + assertFailureCode(result, BAD_VALUE_ERROR, msg="Invalid setAt value should fail") + + +def test_getParameter_setAt_empty_string(collection): + """Test setAt with an empty string fails with BadValue, not treated as no filter.""" + result = execute_admin_command( + collection, {"getParameter": {"allParameters": True, "setAt": ""}} + ) + assertFailureCode(result, BAD_VALUE_ERROR, msg="Empty-string setAt value should fail") From beb08e50cb2531159b47c6aa35f87fa84ee250ab Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:09:39 -0700 Subject: [PATCH 29/51] Add `hello` tests (#632) Signed-off-by: Alina (Xi) Li --- .../tests/system/replication/__init__.py | 0 .../system/replication/commands/__init__.py | 0 .../replication/commands/hello/__init__.py | 0 .../hello/test_hello_command_value.py | 71 ++++++ .../commands/hello/test_hello_comment.py | 122 +++++++++ .../commands/hello/test_hello_consistency.py | 232 ++++++++++++++++++ .../commands/hello/test_hello_error_cases.py | 109 ++++++++ .../commands/hello/test_hello_replica_set.py | 190 ++++++++++++++ .../hello/test_hello_response_structure.py | 165 +++++++++++++ .../hello/test_hello_sasl_supported_mechs.py | 140 +++++++++++ .../commands/hello/test_smoke_hello.py | 21 ++ .../utils/replication_test_case.py | 24 ++ documentdb_tests/framework/property_checks.py | 17 ++ 13 files changed, 1091 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/replication/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_command_value.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_comment.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_error_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_replica_set.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_sasl_supported_mechs.py create mode 100644 documentdb_tests/compatibility/tests/system/replication/commands/hello/test_smoke_hello.py diff --git a/documentdb_tests/compatibility/tests/system/replication/__init__.py b/documentdb_tests/compatibility/tests/system/replication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/__init__.py b/documentdb_tests/compatibility/tests/system/replication/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/__init__.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_command_value.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_command_value.py new file mode 100644 index 000000000..2d2e97cd0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_command_value.py @@ -0,0 +1,71 @@ +"""Tests for hello command value type acceptance. + +Validates that the hello command accepts all standard BSON types as +the command value (the ``1`` in ``{hello: 1}``). +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Command Value Type Acceptance]: the hello command accepts +# all standard BSON types as the command value. +HELLO_COMMAND_VALUE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"command_value_{tid}", + command=lambda ctx, v=val: {"hello": v}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg=f"hello should accept {tid} as command value", + ) + for tid, val in [ + ("int32", 1), + ("bool_true", True), + ("bool_false", False), + ("double", 1.0), + ("string", "test"), + ("null", None), + ("object_empty", {}), + ("object", {"key": "val"}), + ("array_empty", []), + ("array", [1, 2]), + ("int64", Int64(1)), + ("decimal128", Decimal128("1")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x00", 0)), + ("objectid", ObjectId()), + ("regex", Regex(".*")), + ("timestamp", Timestamp(0, 0)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("code", Code("function(){}")), + ] +] + + +@pytest.mark.parametrize("test", pytest_params(HELLO_COMMAND_VALUE_TESTS)) +def test_hello_command_value(collection, test): + """Test hello command value type acceptance.""" + 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/system/replication/commands/hello/test_hello_comment.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_comment.py new file mode 100644 index 000000000..92a207637 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_comment.py @@ -0,0 +1,122 @@ +"""Tests for hello command comment parameter, combined parameters, +and unrecognized fields. + +Validates that the comment parameter accepts all BSON types, that +combined parameters work together, and that unrecognized fields are +handled correctly. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64, ObjectId + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Comment Type Acceptance]: the comment parameter accepts all +# BSON types without error. +HELLO_COMMENT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"comment_{tid}", + command=lambda ctx, v=val: {"hello": 1, "comment": v}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg=f"hello should accept {tid} as comment value", + ) + for tid, val in [ + ("string", "a log comment"), + ("int32", 42), + ("double", 3.14), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("object", {"key": "val"}), + ("array", [1, "two", 3]), + ("int64", Int64(999)), + ("decimal128", Decimal128("1.5")), + ("objectid", ObjectId()), + ] +] + +# Property [Combined Parameters]: hello accepts saslSupportedMechs and +# comment together or individually. +HELLO_COMBINED_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "combined_both_params", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.testuser", + "comment": "both params", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept both saslSupportedMechs and comment", + ), + ReplicationTestCase( + "combined_comment_only", + command=lambda ctx: {"hello": 1, "comment": "only comment"}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should succeed with only comment parameter", + ), + ReplicationTestCase( + "combined_sasl_only", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.testuser", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should succeed with only saslSupportedMechs parameter", + ), +] + +# Property [Unrecognized Field Handling]: hello silently ignores +# unrecognized fields. +HELLO_UNRECOGNIZED_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "unrecognized_single_field", + command=lambda ctx: {"hello": 1, "unknownField": "value"}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should ignore unrecognized field", + ), + ReplicationTestCase( + "unrecognized_multiple_fields", + command=lambda ctx: { + "hello": 1, + "unknownField1": 1, + "unknownField2": 2, + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should ignore multiple unrecognized fields", + ), +] + +HELLO_COMMENT_ALL_TESTS: list[ReplicationTestCase] = ( + HELLO_COMMENT_TESTS + HELLO_COMBINED_TESTS + HELLO_UNRECOGNIZED_FIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_COMMENT_ALL_TESTS)) +def test_hello_comment(collection, test): + """Test hello comment parameter, combined parameters, and unrecognized fields.""" + 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/system/replication/commands/hello/test_hello_consistency.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_consistency.py new file mode 100644 index 000000000..bf5d86c30 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_consistency.py @@ -0,0 +1,232 @@ +"""Tests for hello command consistency, idempotency, legacy compatibility, +execution context, standalone behavior, and read-only behavior. + +Validates that hello returns consistent results, works across databases, +is compatible with legacy isMaster, and does not modify state. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gte, NotExists + +# Property [Execution Context]: hello succeeds on any database context. +HELLO_CONTEXT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "context_admin_database", + command=lambda ctx: {"hello": 1}, + use_admin=True, + expected={"ok": Eq(1.0)}, + msg="hello should succeed on admin database", + ), + ReplicationTestCase( + "context_user_database", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should succeed on non-admin database", + ), +] + +# Property [Primary/Standalone Defaults]: on a standalone or primary node, +# isWritablePrimary is true and readOnly is false. +HELLO_STANDALONE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "primary_isWritablePrimary_true", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"isWritablePrimary": Eq(True)}, + msg="hello should return isWritablePrimary true on standalone/primary", + ), + ReplicationTestCase( + "primary_readOnly_false", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"readOnly": Eq(False)}, + msg="hello should return readOnly false on standalone/primary", + ), +] + +# Property [Legacy isMaster Compatibility]: isMaster and ismaster still work +# and return compatible output. +HELLO_LEGACY_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "legacy_isMaster_succeeds", + command=lambda ctx: {"isMaster": 1}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should support isMaster alias with ok: 1.0", + ), + ReplicationTestCase( + "legacy_ismaster_lowercase_succeeds", + command=lambda ctx: {"ismaster": 1}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should support ismaster (lowercase) alias with ok: 1.0", + ), +] + +HELLO_CONSISTENCY_ALL_TESTS: list[ReplicationTestCase] = ( + HELLO_CONTEXT_TESTS + HELLO_STANDALONE_TESTS + HELLO_LEGACY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_CONSISTENCY_ALL_TESTS)) +def test_hello_consistency(collection, test): + """Test hello command consistency, context, and legacy compatibility.""" + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx, result), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Static Field Consistency]: static fields are identical across +# consecutive hello calls. +def test_hello_consistency_static_fields(collection): + """Test hello returns identical static fields across consecutive calls.""" + r1 = execute_command(collection, {"hello": 1}) + r2 = execute_command(collection, {"hello": 1}) + assertResult( + r2, + expected={ + "maxBsonObjectSize": Eq(r1["maxBsonObjectSize"]), + "maxMessageSizeBytes": Eq(r1["maxMessageSizeBytes"]), + "maxWriteBatchSize": Eq(r1["maxWriteBatchSize"]), + "minWireVersion": Eq(r1["minWireVersion"]), + "maxWireVersion": Eq(r1["maxWireVersion"]), + }, + msg="hello should return identical static fields across consecutive calls", + raw_res=True, + ) + + +# Property [Admin and User DB Consistency]: static fields match between admin +# and user databases. +def test_hello_admin_and_user_db_static_fields_match(collection): + """Test hello static fields match between admin and user databases.""" + r_admin = execute_admin_command(collection, {"hello": 1}) + r_user = execute_command(collection, {"hello": 1}) + assertResult( + r_user, + expected={ + "maxBsonObjectSize": Eq(r_admin["maxBsonObjectSize"]), + "maxMessageSizeBytes": Eq(r_admin["maxMessageSizeBytes"]), + "maxWriteBatchSize": Eq(r_admin["maxWriteBatchSize"]), + "minWireVersion": Eq(r_admin["minWireVersion"]), + "maxWireVersion": Eq(r_admin["maxWireVersion"]), + }, + msg="hello should return matching static fields on admin and user db", + raw_res=True, + ) + + +# Property [localTime Monotonicity]: localTime is non-decreasing across calls. +def test_hello_localTime_monotonic(collection): + """Test hello localTime is monotonically non-decreasing.""" + r1 = execute_command(collection, {"hello": 1}) + r2 = execute_command(collection, {"hello": 1}) + assertResult( + r2, + expected={"localTime": Gte(r1["localTime"])}, + msg="hello should return non-decreasing localTime across calls", + raw_res=True, + ) + + +# Property [connectionId Stability]: connectionId is stable on the same connection. +def test_hello_connectionId_stable(collection): + """Test hello connectionId remains stable on same connection.""" + r1 = execute_command(collection, {"hello": 1}) + r2 = execute_command(collection, {"hello": 1}) + assertResult( + r2, + expected={"connectionId": Eq(r1["connectionId"])}, + msg="hello should return stable connectionId on same connection", + raw_res=True, + ) + + +# Property [Legacy Field Compatibility]: hello and isMaster return the same +# values for common fields. +def test_hello_legacy_isMaster_fields_match(collection): + """Test hello and isMaster return same values for common fields.""" + r_hello = execute_command(collection, {"hello": 1}) + r_ismaster = execute_command(collection, {"isMaster": 1}) + assertResult( + r_ismaster, + expected={ + "maxBsonObjectSize": Eq(r_hello["maxBsonObjectSize"]), + "maxMessageSizeBytes": Eq(r_hello["maxMessageSizeBytes"]), + "maxWriteBatchSize": Eq(r_hello["maxWriteBatchSize"]), + "minWireVersion": Eq(r_hello["minWireVersion"]), + "maxWireVersion": Eq(r_hello["maxWireVersion"]), + "readOnly": Eq(r_hello["readOnly"]), + "connectionId": Eq(r_hello["connectionId"]), + "logicalSessionTimeoutMinutes": Eq(r_hello["logicalSessionTimeoutMinutes"]), + }, + msg="hello should return matching fields with isMaster alias", + raw_res=True, + ) + + +# Property [Read-Only Behavior]: hello does not modify server state. +def test_hello_read_only_behavior(collection): + """Test hello static fields unchanged after inserting a document.""" + r_before = execute_command(collection, {"hello": 1}) + collection.insert_one({"_id": 1, "data": "test"}) + r_after = execute_command(collection, {"hello": 1}) + assertResult( + r_after, + expected={ + "maxBsonObjectSize": Eq(r_before["maxBsonObjectSize"]), + "maxMessageSizeBytes": Eq(r_before["maxMessageSizeBytes"]), + "maxWriteBatchSize": Eq(r_before["maxWriteBatchSize"]), + "minWireVersion": Eq(r_before["minWireVersion"]), + "maxWireVersion": Eq(r_before["maxWireVersion"]), + }, + msg="hello should return unchanged static fields after insert", + raw_res=True, + ) + + +# Property [Standalone RS Fields Absent]: on standalone, replica set fields +# are absent from the hello response. +@pytest.mark.requires(replication=False) +def test_hello_standalone_rs_fields_absent(collection): + """Test hello does not return replica set fields on standalone.""" + result = execute_command(collection, {"hello": 1}) + assertResult( + result, + expected={ + "hosts": NotExists(), + "setName": NotExists(), + "setVersion": NotExists(), + "secondary": NotExists(), + "primary": NotExists(), + "me": NotExists(), + "electionId": NotExists(), + "lastWrite": NotExists(), + "passives": NotExists(), + "arbiters": NotExists(), + }, + msg="hello should not return replica set fields on standalone", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_error_cases.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_error_cases.py new file mode 100644 index 000000000..823f344e3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_error_cases.py @@ -0,0 +1,109 @@ +"""Tests for hello command error cases. + +Validates case-sensitive command name rejection, saslSupportedMechs +invalid format rejection, and saslSupportedMechs type rejection. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + COMMAND_NOT_FOUND_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Case-Sensitive Command Name]: the hello command is case-sensitive +# and rejects case mismatches. +HELLO_CASE_ERROR_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "case_capital_H", + command=lambda ctx: {"Hello": 1}, + use_admin=False, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="hello should reject 'Hello' (capital H)", + ), + ReplicationTestCase( + "case_all_caps", + command=lambda ctx: {"HELLO": 1}, + use_admin=False, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="hello should reject 'HELLO' (all caps)", + ), + ReplicationTestCase( + "case_mixed", + command=lambda ctx: {"heLLo": 1}, + use_admin=False, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="hello should reject 'heLLo' (mixed case)", + ), +] + +# Property [saslSupportedMechs Invalid Format]: hello rejects strings +# that do not follow the "db.user" format. +SASL_INVALID_FORMAT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "sasl_no_dot_separator", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "noDotSeparator", + }, + use_admin=False, + error_code=BAD_VALUE_ERROR, + msg="hello should reject saslSupportedMechs without db.user format", + ), + ReplicationTestCase( + "sasl_empty_string", + command=lambda ctx: {"hello": 1, "saslSupportedMechs": ""}, + use_admin=False, + error_code=BAD_VALUE_ERROR, + msg="hello should reject empty string saslSupportedMechs", + ), +] + +# Property [saslSupportedMechs Type Rejection]: hello rejects non-string +# types for saslSupportedMechs. +SASL_TYPE_REJECTION_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + f"sasl_type_{tid}", + command=lambda ctx, v=val: {"hello": 1, "saslSupportedMechs": v}, + use_admin=False, + error_code=err, + msg=f"hello should reject {tid} as saslSupportedMechs", + ) + for tid, val, err in [ + ("int", 123, TYPE_MISMATCH_ERROR), + ("bool", True, TYPE_MISMATCH_ERROR), + ("array", ["admin.user"], TYPE_MISMATCH_ERROR), + ("object", {"db": "admin"}, BAD_VALUE_ERROR), + ("null", None, TYPE_MISMATCH_ERROR), + ] +] + +HELLO_ERROR_ALL_TESTS: list[ReplicationTestCase] = ( + HELLO_CASE_ERROR_TESTS + SASL_INVALID_FORMAT_TESTS + SASL_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_ERROR_ALL_TESTS)) +def test_hello_error_cases(collection, test): + """Test hello command error cases.""" + 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/system/replication/commands/hello/test_hello_replica_set.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_replica_set.py new file mode 100644 index 000000000..06180c2e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_replica_set.py @@ -0,0 +1,190 @@ +"""Tests for hello command replica set response fields. + +Validates required replica set fields, conditional fields, and +behavioral checks when connected to a replica set member. +All tests in this file require a replica set connection. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + ContainsElement, + Eq, + Gte, + IsType, + Lte, + NonEmptyStr, +) + +pytestmark = [pytest.mark.requires(replication=True)] + +# Property [Required Replica Set Fields]: hello response includes hosts, +# setName, setVersion, secondary, primary, me, and lastWrite when connected +# to a replica set member. +RS_REQUIRED_FIELD_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "rs_hosts_is_array", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"hosts": IsType("array")}, + msg="hello should return hosts as array on replica set member", + ), + ReplicationTestCase( + "rs_setName", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"setName": NonEmptyStr()}, + msg="hello should return setName as non-empty string", + ), + ReplicationTestCase( + "rs_setVersion", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"setVersion": Gte(1)}, + msg="hello should return setVersion as positive integer", + ), + ReplicationTestCase( + "rs_secondary_is_bool", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"secondary": IsType("bool")}, + msg="hello should return secondary as boolean", + ), + ReplicationTestCase( + "rs_primary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"primary": NonEmptyStr()}, + msg="hello should return primary as non-empty string", + ), + ReplicationTestCase( + "rs_me", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"me": NonEmptyStr()}, + msg="hello should return me as non-empty string", + ), + ReplicationTestCase( + "rs_lastWrite_opTime", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"lastWrite": {"opTime": IsType("object")}}, + msg="hello should return lastWrite.opTime as object", + ), + ReplicationTestCase( + "rs_lastWrite_lastWriteDate", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"lastWrite": {"lastWriteDate": IsType("date")}}, + msg="hello should return lastWrite.lastWriteDate as date", + ), + ReplicationTestCase( + "rs_lastWrite_majorityOpTime", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"lastWrite": {"majorityOpTime": IsType("object")}}, + msg="hello should return lastWrite.majorityOpTime as object", + ), + ReplicationTestCase( + "rs_lastWrite_majorityWriteDate", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"lastWrite": {"majorityWriteDate": IsType("date")}}, + msg="hello should return lastWrite.majorityWriteDate as date", + ), + ReplicationTestCase( + "rs_electionId_on_primary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"electionId": IsType("objectId")}, + msg="hello should return electionId as ObjectId on primary", + ), +] + +# Property [Primary Node Invariants]: on a primary, isWritablePrimary is true, +# secondary is false, primary equals me, and hosts contains both. +RS_PRIMARY_INVARIANT_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "rs_primary_isWritablePrimary_and_not_secondary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"isWritablePrimary": Eq(True), "secondary": Eq(False)}, + msg="hello should return isWritablePrimary=true and secondary=false on primary", + ), + ReplicationTestCase( + "rs_primary_equals_me", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: {"primary": Eq(result.get("me", "MISSING"))}, + msg="hello should return primary equal to me on primary node", + ), + ReplicationTestCase( + "rs_hosts_contains_primary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: {"hosts": ContainsElement(result.get("primary", "MISSING"))}, + msg="hello should return hosts array containing the primary", + ), + ReplicationTestCase( + "rs_me_in_hosts", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: {"hosts": ContainsElement(result.get("me", "MISSING"))}, + msg="hello should return hosts array containing me", + ), +] + +# Property [lastWrite Date Ordering]: lastWrite dates have expected ordering. +RS_LASTWRITE_DATE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "rs_lastWriteDate_not_future", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: { + "lastWrite": {"lastWriteDate": Lte(datetime.now(tz=timezone.utc))}, + }, + msg="hello lastWrite.lastWriteDate should be <= current time", + ), + ReplicationTestCase( + "rs_majorityWriteDate_lte_lastWriteDate", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: { + "lastWrite": { + "majorityWriteDate": Lte(result["lastWrite"]["lastWriteDate"]), + }, + }, + msg="hello majorityWriteDate should be <= lastWriteDate", + ), +] + +HELLO_RS_ALL_TESTS: list[ReplicationTestCase] = ( + RS_REQUIRED_FIELD_TESTS + RS_PRIMARY_INVARIANT_TESTS + RS_LASTWRITE_DATE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_RS_ALL_TESTS)) +def test_hello_replica_set(collection, test): + """Test hello replica set response fields.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx, result), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_response_structure.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_response_structure.py new file mode 100644 index 000000000..a3d4eec53 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_response_structure.py @@ -0,0 +1,165 @@ +"""Tests for hello command response structure. + +Validates common response fields (all instances), topologyVersion +fields, and wire version fields using property checks. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gte, IsType + +# Property [Common Response Fields]: hello response includes all required +# fields with correct types and values. +RESPONSE_COMMON_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "response_isWritablePrimary", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"isWritablePrimary": IsType("bool")}, + msg="hello should return isWritablePrimary as boolean", + ), + ReplicationTestCase( + "response_maxBsonObjectSize", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"maxBsonObjectSize": Eq(16_777_216)}, + msg="hello should return maxBsonObjectSize equal to 16777216", + ), + ReplicationTestCase( + "response_maxMessageSizeBytes", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"maxMessageSizeBytes": Eq(48_000_000)}, + msg="hello should return maxMessageSizeBytes equal to 48000000", + ), + ReplicationTestCase( + "response_maxWriteBatchSize", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"maxWriteBatchSize": Eq(100_000)}, + msg="hello should return maxWriteBatchSize equal to 100000", + ), + ReplicationTestCase( + "response_localTime", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"localTime": IsType("date")}, + msg="hello should return localTime as date", + ), + ReplicationTestCase( + "response_logicalSessionTimeoutMinutes", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"logicalSessionTimeoutMinutes": Gte(1)}, + msg="hello should return logicalSessionTimeoutMinutes as positive integer", + ), + ReplicationTestCase( + "response_connectionId", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"connectionId": Gte(1)}, + msg="hello should return connectionId as positive integer", + ), + ReplicationTestCase( + "response_readOnly", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"readOnly": IsType("bool")}, + msg="hello should return readOnly as boolean", + ), + ReplicationTestCase( + "response_ok", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should return ok equal to 1.0", + ), +] + +# Property [topologyVersion]: hello response contains topologyVersion with +# processId (ObjectId) and counter (non-negative int64). +RESPONSE_TOPOLOGY_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "topology_processId", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"topologyVersion": {"processId": IsType("objectId")}}, + msg="hello should return topologyVersion.processId as ObjectId", + ), + ReplicationTestCase( + "topology_counter", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"topologyVersion": {"counter": Gte(0)}}, + msg="hello should return topologyVersion.counter as non-negative", + ), +] + +# Property [Wire Version]: wire version fields indicate protocol compatibility. +RESPONSE_WIRE_VERSION_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "wire_min_non_negative", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"minWireVersion": Gte(0)}, + msg="hello should return minWireVersion >= 0", + ), + ReplicationTestCase( + "wire_max_reasonable", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected={"maxWireVersion": Gte(21)}, + msg="hello should return maxWireVersion >= 21 for MongoDB 7.0+", + ), + ReplicationTestCase( + "wire_max_gte_min", + command=lambda ctx: {"hello": 1}, + use_admin=False, + expected=lambda ctx, result: { + "maxWireVersion": Gte(result.get("minWireVersion", 0)), + }, + msg="hello should return maxWireVersion >= minWireVersion", + ), +] + +HELLO_RESPONSE_ALL_TESTS: list[ReplicationTestCase] = ( + RESPONSE_COMMON_TESTS + RESPONSE_TOPOLOGY_TESTS + RESPONSE_WIRE_VERSION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_RESPONSE_ALL_TESTS)) +def test_hello_response_structure(collection, test): + """Test hello response field types and values.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx, result), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [topologyVersion Stability]: processId remains stable across calls. +def test_hello_response_topologyVersion_processId_stable(collection): + """Test hello topologyVersion.processId is stable across calls.""" + r1 = execute_command(collection, {"hello": 1}) + r2 = execute_command(collection, {"hello": 1}) + assertResult( + r2, + expected={"topologyVersion": {"processId": Eq(r1["topologyVersion"]["processId"])}}, + msg="hello should return stable topologyVersion.processId across calls", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_sasl_supported_mechs.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_sasl_supported_mechs.py new file mode 100644 index 000000000..1943ecfcd --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_sasl_supported_mechs.py @@ -0,0 +1,140 @@ +"""Tests for hello command saslSupportedMechs parameter acceptance. + +Validates valid usage, format edge cases, and accepted variations +for the saslSupportedMechs parameter. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.replication.utils.replication_test_case import ( # noqa: E501 + ReplicationTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [saslSupportedMechs Valid Usage]: hello accepts valid +# "db.user" format strings for saslSupportedMechs. +SASL_VALID_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "sasl_nonexistent_user", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.nonExistentUser", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should succeed for non-existent user in saslSupportedMechs", + ), + ReplicationTestCase( + "sasl_other_db_prefix", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "otherdb.someuser", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept non-admin database prefix in saslSupportedMechs", + ), +] + +# Property [saslSupportedMechs Format Edge Cases]: hello accepts +# borderline "db.user" format variations that still contain a dot. +SASL_FORMAT_EDGE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "sasl_empty_db_component", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": ".noDatabase", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with empty database component", + ), + ReplicationTestCase( + "sasl_empty_username", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with empty username", + ), + ReplicationTestCase( + "sasl_dot_only", + command=lambda ctx: {"hello": 1, "saslSupportedMechs": "."}, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept dot-only saslSupportedMechs", + ), +] + +# Property [saslSupportedMechs Edge Cases]: hello handles edge cases +# in the "db.user" format string. +SASL_EDGE_CASE_TESTS: list[ReplicationTestCase] = [ + ReplicationTestCase( + "sasl_dots_in_username", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.user.with.dots", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with dots in username", + ), + ReplicationTestCase( + "sasl_special_chars_in_db", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "a]dmin.user", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with special chars in db name", + ), + ReplicationTestCase( + "sasl_long_username", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "admin.a_very_long_username_that_exceeds_typical_lengths", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept saslSupportedMechs with long username", + ), + ReplicationTestCase( + "sasl_external_db", + command=lambda ctx: { + "hello": 1, + "saslSupportedMechs": "$external.user", + }, + use_admin=False, + expected={"ok": Eq(1.0)}, + msg="hello should accept $external database prefix in saslSupportedMechs", + ), +] + +HELLO_SASL_ALL_TESTS: list[ReplicationTestCase] = ( + SASL_VALID_TESTS + SASL_FORMAT_EDGE_TESTS + SASL_EDGE_CASE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(HELLO_SASL_ALL_TESTS)) +def test_hello_sasl_supported_mechs(collection, test): + """Test hello saslSupportedMechs parameter acceptance.""" + 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/system/replication/commands/hello/test_smoke_hello.py b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_smoke_hello.py new file mode 100644 index 000000000..d7746c83a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_smoke_hello.py @@ -0,0 +1,21 @@ +""" +Smoke test for hello command. + +Tests basic hello command functionality by verifying the command returns +a successful response with ok: 1.0. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.smoke] + + +def test_smoke_hello(collection): + """Test basic hello command behavior.""" + result = execute_command(collection, {"hello": 1}) + + expected = {"ok": 1.0} + assertSuccessPartial(result, expected, msg="hello should return ok: 1.0") diff --git a/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py b/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py index 5b93302e2..449e89bfa 100644 --- a/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py +++ b/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py @@ -2,9 +2,12 @@ from __future__ import annotations +import inspect from dataclasses import dataclass +from typing import Any from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, CommandTestCase, ) @@ -16,6 +19,10 @@ class ReplicationTestCase(CommandTestCase): Extends CommandTestCase with a ``use_admin`` flag that controls whether the command is executed against the admin database. + The ``expected`` field supports an extended callable signature + ``(ctx, result) -> dict`` for assertions that reference dynamic + values from the command result itself. + Attributes: use_admin: If True (the default), execute against the admin database via ``execute_admin_command``. If False, execute @@ -23,3 +30,20 @@ class ReplicationTestCase(CommandTestCase): """ use_admin: bool = True + + def build_expected( + self, + ctx: CommandContext, + result: dict[str, Any] | None = None, + ) -> dict[str, Any] | list[dict[str, Any]] | None: + """Resolve expected, optionally passing the command result. + + If ``expected`` is a callable that accepts two parameters + (ctx, result), the result is forwarded. Otherwise, falls + back to the parent implementation. + """ + if callable(self.expected) and not isinstance(self.expected, (dict, list)): + sig = inspect.signature(self.expected) + if len(sig.parameters) == 2: + return self.expected(ctx, result) + return super().build_expected(ctx) diff --git a/documentdb_tests/framework/property_checks.py b/documentdb_tests/framework/property_checks.py index 0f029cb54..2bb4eb7e2 100644 --- a/documentdb_tests/framework/property_checks.py +++ b/documentdb_tests/framework/property_checks.py @@ -311,6 +311,23 @@ def __repr__(self) -> str: return f"{type(self).__name__}({self.minimum!r})" +class Lte(Check): + """Assert that the field is less than or equal to a value.""" + + def __init__(self, maximum: Any) -> None: + self.maximum = maximum + + def check(self, value: Any, path: str) -> str | None: + if value is _FIELD_ABSENT: + return f"expected '{path}' <= {self.maximum!r}, but field is missing" + if value > self.maximum: + return f"expected '{path}' <= {self.maximum!r}, got {value!r}" + return None + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.maximum!r})" + + class NonEmptyStr(Check): """Assert that the field is a non-empty string. From b8389a436ec0b4205562e8c07ff9d775fd53e8fe Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:28:26 -0700 Subject: [PATCH 30/51] Add getCmdLineOpts tests (#635) Signed-off-by: PatersonProjects --- .../commands/getCmdLineOpts/__init__.py | 0 .../test_getCmdLineOpts_argument_handling.py | 66 +++++++++++++++++++ .../test_getCmdLineOpts_consistency.py | 35 ++++++++++ .../test_getCmdLineOpts_error_conditions.py | 63 ++++++++++++++++++ .../test_getCmdLineOpts_response_structure.py | 47 +++++++++++++ 5 files changed, 211 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_argument_handling.py new file mode 100644 index 000000000..cc4db7849 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_argument_handling.py @@ -0,0 +1,66 @@ +"""Tests for getCmdLineOpts command argument handling. + +Validates that getCmdLineOpts accepts any BSON type as its argument value. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import FLOAT_INFINITY, BsonType + +pytestmark = pytest.mark.admin + + +BSON_TYPE_SPEC = BsonTypeTestCase( + id="getCmdLineOpts_arg", + msg="getCmdLineOpts should accept any BSON type as argument value", + keyword="getCmdLineOpts", + valid_types=list(BsonType), +) + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases([BSON_TYPE_SPEC]) + +EDGE_CASES: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "int_0", + command={"getCmdLineOpts": 0}, + checks={"ok": Eq(1.0)}, + msg="Should accept int 0", + ), + DiagnosticTestCase( + "int_neg1", + command={"getCmdLineOpts": -1}, + checks={"ok": Eq(1.0)}, + msg="Should accept int -1", + ), + DiagnosticTestCase( + "infinity", + command={"getCmdLineOpts": FLOAT_INFINITY}, + checks={"ok": Eq(1.0)}, + msg="Should accept infinity", + ), +] + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_getCmdLineOpts_argument_types(collection, bson_type, sample_value, spec): + """Test that getCmdLineOpts accepts various BSON types as argument value.""" + result = execute_admin_command(collection, {spec.keyword: sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=spec.msg, raw_res=True) + + +@pytest.mark.parametrize("test", pytest_params(EDGE_CASES)) +def test_getCmdLineOpts_argument_edge_cases(collection, test): + """Test that getCmdLineOpts accepts numeric edge case values.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_consistency.py new file mode 100644 index 000000000..7da27970c --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_consistency.py @@ -0,0 +1,35 @@ +"""Tests for getCmdLineOpts command output stability. + +Validates that getCmdLineOpts returns consistent results across repeated calls. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = pytest.mark.admin + + +def test_getCmdLineOpts_parsed_stable(collection): + """Test the 'parsed' content is identical across consecutive calls.""" + result1 = execute_admin_command(collection, {"getCmdLineOpts": 1}) + result2 = execute_admin_command(collection, {"getCmdLineOpts": 1}) + assertSuccess( + result2["parsed"], + expected=result1["parsed"], + msg="'parsed' content should be identical across calls", + raw_res=True, + ) + + +def test_getCmdLineOpts_argv_stable(collection): + """Test the 'argv' content is identical across consecutive calls.""" + result1 = execute_admin_command(collection, {"getCmdLineOpts": 1}) + result2 = execute_admin_command(collection, {"getCmdLineOpts": 1}) + assertSuccess( + result2["argv"], + expected=result1["argv"], + msg="'argv' content should be identical across calls", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_error_conditions.py new file mode 100644 index 000000000..2c0fed6af --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_error_conditions.py @@ -0,0 +1,63 @@ +"""Tests for getCmdLineOpts command error conditions. + +Validates that invalid usages of getCmdLineOpts produce appropriate errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_FOUND_ERROR, + UNAUTHORIZED_ERROR, + UNKNOWN_PIPELINE_STAGE_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="non_admin_database", + command={"getCmdLineOpts": 1}, + use_admin=False, + error_code=UNAUTHORIZED_ERROR, + msg="getCmdLineOpts may only be run against the admin database", + ), + DiagnosticTestCase( + id="unrecognized_field", + command={"getCmdLineOpts": 1, "unknownField": 1}, + use_admin=True, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject unrecognized fields", + ), + DiagnosticTestCase( + id="case_sensitive", + command={"GetCmdLineOpts": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Case-mismatched command name should fail", + ), + DiagnosticTestCase( + id="as_aggregation_stage", + command={"aggregate": "test", "pipeline": [{"$getCmdLineOpts": {}}], "cursor": {}}, + use_admin=False, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="$getCmdLineOpts is not a valid aggregation stage", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_getCmdLineOpts_error_conditions(collection, test): + """Verifies getCmdLineOpts rejects invalid usages with appropriate error codes.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_response_structure.py new file mode 100644 index 000000000..2bf73dd6a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getCmdLineOpts/test_getCmdLineOpts_response_structure.py @@ -0,0 +1,47 @@ +"""Tests for getCmdLineOpts command response structure. + +Validates presence, types, and values of response fields. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType + +pytestmark = pytest.mark.admin + + +PROPERTY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="ok_is_1", + checks={"ok": Eq(1.0)}, + msg="'ok' field should be 1.0", + ), + DiagnosticTestCase( + id="argv_is_array", + checks={"argv": IsType("array")}, + msg="'argv' field should be an array", + ), + DiagnosticTestCase( + id="argv_first_is_string", + checks={"argv.0": IsType("string")}, + msg="'argv' first element (binary path) should be a string", + ), + DiagnosticTestCase( + id="parsed_is_object", + checks={"parsed": IsType("object")}, + msg="'parsed' field should be a document", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PROPERTY_TESTS)) +def test_getCmdLineOpts_response_properties(collection, test): + """Verifies getCmdLineOpts response fields have expected types and values.""" + result = execute_admin_command(collection, {"getCmdLineOpts": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) From 6f9546b912f8b625b20658241cd1f4c6e434ab8c Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:36:55 -0700 Subject: [PATCH 31/51] Add `top` tests (#636) Signed-off-by: Alina (Xi) Li --- .../diagnostic/commands/top/__init__.py | 0 .../top/test_top_argument_handling.py | 157 +++++++++++++ .../commands/top/test_top_consistency.py | 178 +++++++++++++++ .../commands/top/test_top_core_behavior.py | 216 ++++++++++++++++++ .../commands/top/test_top_errors.py | 64 ++++++ .../top/test_top_response_structure.py | 116 ++++++++++ 6 files changed, 731 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_argument_handling.py new file mode 100644 index 000000000..e40863497 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_argument_handling.py @@ -0,0 +1,157 @@ +"""Tests for top command argument handling. + +Validates that top accepts any BSON type as its argument value and +accepts unrecognized fields. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + +# Property [BSON Type Acceptance]: top accepts any non-deprecated BSON type as command value. +ARGUMENT_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "int_1", command={"top": 1}, checks={"ok": Eq(1.0)}, msg="Should accept int 1" + ), + DiagnosticTestCase( + "int_0", command={"top": 0}, checks={"ok": Eq(1.0)}, msg="Should accept int 0" + ), + DiagnosticTestCase( + "int_neg1", command={"top": -1}, checks={"ok": Eq(1.0)}, msg="Should accept int -1" + ), + DiagnosticTestCase( + "bool_true", command={"top": True}, checks={"ok": Eq(1.0)}, msg="Should accept true" + ), + DiagnosticTestCase( + "bool_false", + command={"top": False}, + checks={"ok": Eq(1.0)}, + msg="Should accept false", + ), + DiagnosticTestCase( + "string", command={"top": "hello"}, checks={"ok": Eq(1.0)}, msg="Should accept string" + ), + DiagnosticTestCase( + "null", command={"top": None}, checks={"ok": Eq(1.0)}, msg="Should accept null" + ), + DiagnosticTestCase( + "empty_object", + command={"top": {}}, + checks={"ok": Eq(1.0)}, + msg="Should accept empty object", + ), + DiagnosticTestCase( + "empty_array", + command={"top": []}, + checks={"ok": Eq(1.0)}, + msg="Should accept empty array", + ), + DiagnosticTestCase( + "double", command={"top": 1.5}, checks={"ok": Eq(1.0)}, msg="Should accept double" + ), + DiagnosticTestCase( + "int64", command={"top": Int64(1)}, checks={"ok": Eq(1.0)}, msg="Should accept int64" + ), + DiagnosticTestCase( + "decimal128", + command={"top": Decimal128("1")}, + checks={"ok": Eq(1.0)}, + msg="Should accept decimal128", + ), + DiagnosticTestCase( + "decimal128_nan", + command={"top": Decimal128("NaN")}, + checks={"ok": Eq(1.0)}, + msg="Should accept decimal128 NaN", + ), + DiagnosticTestCase( + "infinity", + command={"top": float("inf")}, + checks={"ok": Eq(1.0)}, + msg="Should accept infinity", + ), + DiagnosticTestCase( + "date", + command={"top": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + checks={"ok": Eq(1.0)}, + msg="Should accept date", + ), + DiagnosticTestCase( + "binData", + command={"top": Binary(b"")}, + checks={"ok": Eq(1.0)}, + msg="Should accept binData", + ), + DiagnosticTestCase( + "objectId", + command={"top": ObjectId()}, + checks={"ok": Eq(1.0)}, + msg="Should accept objectId", + ), + DiagnosticTestCase( + "regex", + command={"top": Regex("test")}, + checks={"ok": Eq(1.0)}, + msg="Should accept regex", + ), + DiagnosticTestCase( + "timestamp", + command={"top": Timestamp(0, 0)}, + checks={"ok": Eq(1.0)}, + msg="Should accept timestamp", + ), + DiagnosticTestCase( + "minKey", + command={"top": MinKey()}, + checks={"ok": Eq(1.0)}, + msg="Should accept minKey", + ), + DiagnosticTestCase( + "maxKey", + command={"top": MaxKey()}, + checks={"ok": Eq(1.0)}, + msg="Should accept maxKey", + ), + DiagnosticTestCase( + "code", + command={"top": Code("function(){}")}, + checks={"ok": Eq(1.0)}, + msg="Should accept JavaScript code", + ), +] + +# Property [Unrecognized Fields]: top accepts and ignores unrecognized fields. +UNRECOGNIZED_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "single_unrecognized_field", + command={"top": 1, "unknownField": 1}, + checks={"ok": Eq(1.0)}, + msg="top should accept a single unrecognized field", + ), + DiagnosticTestCase( + "multiple_unrecognized_fields", + command={"top": 1, "foo": 1, "bar": "baz", "qux": []}, + checks={"ok": Eq(1.0)}, + msg="top should accept multiple unrecognized fields", + ), +] + +ARGUMENT_HANDLING_TESTS = ARGUMENT_TYPE_TESTS + UNRECOGNIZED_FIELD_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_HANDLING_TESTS)) +def test_top_argument_handling(collection, test): + """Test that top accepts various BSON types and unrecognized fields.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_consistency.py new file mode 100644 index 000000000..aed395b17 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_consistency.py @@ -0,0 +1,178 @@ +"""Tests for top command consistency, visibility, and special collection types. + +Validates idempotency, namespace visibility, system namespace structure, +and behavior with capped collections and views. + +Standalone functions are used throughout because every test requires runtime +logic that DiagnosticTestCase cannot express: multi-call comparisons with +dynamic thresholds, namespace key extraction from the response, conditional +skips, or ad-hoc collection creation (capped, views). +""" + +import pytest + +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.property_checks import Gte, IsType + +pytestmark = pytest.mark.admin + + +# Property [Idempotency]: repeated top calls succeed and counters are non-decreasing. + + +def test_top_repeated_calls_return_ok(collection): + """Test that calling top returns ok after multiple calls.""" + for _ in range(5): + execute_admin_command(collection, {"top": 1}) + result = execute_admin_command(collection, {"top": 1}) + assertSuccessPartial(result, {"ok": 1.0}, msg="top should succeed after repeated calls") + + +def test_top_counters_non_decreasing_count(collection): + """Test that total.count is non-decreasing across two consecutive calls.""" + collection.insert_one({"_id": 1}) + ns = f"{collection.database.name}.{collection.name}" + result1 = execute_admin_command(collection, {"top": 1}) + ns_data1 = result1["totals"].get(ns) + if ns_data1 is None: + raise AssertionError(f"Namespace {ns} not found in first top call") + count1 = ns_data1["total"]["count"] + result2 = execute_admin_command(collection, {"top": 1}) + ns_data2 = result2["totals"].get(ns) + if ns_data2 is None: + raise AssertionError(f"Namespace {ns} not found in second top call") + assertProperties( + ns_data2, + {"total.count": Gte(count1)}, + msg="total.count should be non-decreasing", + raw_res=True, + ) + + +def test_top_counters_non_decreasing_time(collection): + """Test that total.time is non-decreasing across two consecutive calls.""" + collection.insert_one({"_id": 1}) + ns = f"{collection.database.name}.{collection.name}" + result1 = execute_admin_command(collection, {"top": 1}) + ns_data1 = result1["totals"].get(ns) + if ns_data1 is None: + raise AssertionError(f"Namespace {ns} not found in first top call") + time1 = ns_data1["total"]["time"] + result2 = execute_admin_command(collection, {"top": 1}) + ns_data2 = result2["totals"].get(ns) + if ns_data2 is None: + raise AssertionError(f"Namespace {ns} not found in second top call") + assertProperties( + ns_data2, + {"total.time": Gte(time1)}, + msg="total.time should be non-decreasing", + raw_res=True, + ) + + +# Property [Collection Visibility]: active collections appear in totals as db.collection keys. + + +def test_top_newly_created_collection_appears(collection): + """Test that a newly created collection appears in top totals.""" + collection.insert_one({"_id": 1}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties( + ns_data, + {"total": IsType("object")}, + msg=f"Namespace {ns} should appear in top totals", + raw_res=True, + ) + + +def test_top_multiple_collections_appear(collection): + """Test that multiple collections appear in top totals.""" + db = collection.database + coll1 = db.create_collection(f"{collection.name}_multi1") + coll2 = db.create_collection(f"{collection.name}_multi2") + coll1.insert_one({"_id": 1}) + coll2.insert_one({"_id": 1}) + result = execute_admin_command(coll1, {"top": 1}) + ns1 = f"{db.name}.{coll1.name}" + ns2 = f"{db.name}.{coll2.name}" + ns1_data = result["totals"].get(ns1) + ns2_data = result["totals"].get(ns2) + if ns1_data is None: + raise AssertionError(f"Namespace {ns1} not found in top totals") + if ns2_data is None: + raise AssertionError(f"Namespace {ns2} not found in top totals") + assertProperties( + {"ns1": ns1_data, "ns2": ns2_data}, + {"ns1": IsType("object"), "ns2": IsType("object")}, + msg="Both namespaces should appear in top totals", + raw_res=True, + ) + + +# Property [System Collections]: system namespaces have the standard event field structure. + + +def test_top_system_collections_have_event_structure(collection): + """Test that a system namespace in totals has the expected event field structure.""" + collection.insert_one({"_id": 1}) + result = execute_admin_command(collection, {"top": 1}) + system_ns = None + for ns_key in result["totals"]: + if ".system." in ns_key or ns_key.startswith("admin.") or ns_key.startswith("local."): + system_ns = ns_key + break + if system_ns is None: + pytest.skip("No system namespace found in top totals") + ns_data = result["totals"][system_ns] + assertProperties( + ns_data, + {"total": IsType("object"), "total.time": Gte(0), "total.count": Gte(0)}, + msg=f"System namespace {system_ns} should have event fields with time/count", + raw_res=True, + ) + + +# Property [Special Collection Types]: capped collections and views are handled by top. + + +def test_top_tracks_capped_collection(collection): + """Test that a capped collection appears in top totals with expected structure.""" + db = collection.database + coll = db.create_collection(f"{collection.name}_capped", capped=True, size=4096) + coll.insert_one({"_id": 1}) + result = execute_admin_command(coll, {"top": 1}) + ns = f"{db.name}.{coll.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties( + ns_data, + {"total": IsType("object"), "total.time": Gte(0), "total.count": Gte(0)}, + msg="Capped collection should appear in top totals with expected structure", + raw_res=True, + ) + + +def test_top_tracks_view(collection): + """Test that a view namespace appears in top totals.""" + db = collection.database + source_coll = db.create_collection(f"{collection.name}_view_src") + source_coll.insert_one({"_id": 1}) + view_name = f"{collection.name}_view" + db.command("create", view_name, viewOn=source_coll.name, pipeline=[]) + result = execute_admin_command(source_coll, {"top": 1}) + view_ns = f"{db.name}.{view_name}" + view_data = result["totals"].get(view_ns) + if view_data is None: + raise AssertionError(f"Namespace {view_ns} not found in top totals") + assertProperties( + view_data, + {"total": IsType("object")}, + msg=f"View namespace {view_ns} should appear in top totals", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_core_behavior.py new file mode 100644 index 000000000..2f1082f66 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_core_behavior.py @@ -0,0 +1,216 @@ +"""Tests for top command core behavior. + +Validates that counters reflect operations and cross-lock consistency invariants hold. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Gt, Gte + +pytestmark = pytest.mark.admin + + +# Property [Counter Behavior - Insert]: insert operations populate insert, writeLock, and time. +INSERT_COUNTER_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "insert_count_gte_1", + command={"top": 1}, + checks={"insert.count": Gte(1)}, + msg="insert.count should be >= 1 after inserts", + ), + DiagnosticTestCase( + "insert_writeLock_count_gte_1", + command={"top": 1}, + checks={"writeLock.count": Gte(1)}, + msg="writeLock.count should be >= 1 after inserts", + ), + DiagnosticTestCase( + "insert_time_gt_0", + command={"top": 1}, + checks={"insert.time": Gt(0)}, + msg="insert.time should be > 0 after inserts", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INSERT_COUNTER_TESTS)) +def test_top_after_inserts(collection, test): + """Test counters after insert operations.""" + collection.insert_many([{"_id": i} for i in range(10)]) + result = execute_admin_command(collection, test.command) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) + + +# Property [Counter Behavior - Query]: find operations populate queries, readLock, and time. +QUERY_COUNTER_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "queries_count_gte_1", + command={"top": 1}, + checks={"queries.count": Gte(1)}, + msg="queries.count should be >= 1 after query", + ), + DiagnosticTestCase( + "query_readLock_count_gte_1", + command={"top": 1}, + checks={"readLock.count": Gte(1)}, + msg="readLock.count should be >= 1 after query", + ), + DiagnosticTestCase( + "queries_time_gt_0", + command={"top": 1}, + checks={"queries.time": Gt(0)}, + msg="queries.time should be > 0 after query", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(QUERY_COUNTER_TESTS)) +def test_top_after_query(collection, test): + """Test counters after find operations.""" + collection.insert_one({"_id": 1}) + list(collection.find()) + result = execute_admin_command(collection, test.command) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) + + +# Property [Counter Behavior - Update]: update operations populate the update counter. +UPDATE_COUNTER_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "update_count_gte_1", + command={"top": 1}, + checks={"update.count": Gte(1)}, + msg="update.count should be >= 1 after update", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(UPDATE_COUNTER_TESTS)) +def test_top_after_update(collection, test): + """Test counters after update operations.""" + collection.insert_one({"_id": 1, "a": 1}) + collection.update_one({"_id": 1}, {"$set": {"a": 2}}) + result = execute_admin_command(collection, test.command) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) + + +# Property [Counter Behavior - Remove]: delete operations populate the remove counter. +REMOVE_COUNTER_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "remove_count_gte_1", + command={"top": 1}, + checks={"remove.count": Gte(1)}, + msg="remove.count should be >= 1 after delete", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(REMOVE_COUNTER_TESTS)) +def test_top_after_remove(collection, test): + """Test counters after delete operations.""" + collection.insert_one({"_id": 1}) + collection.delete_one({"_id": 1}) + result = execute_admin_command(collection, test.command) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) + + +# Property [Cross-Lock Invariants]: aggregate lock counters are >= the sum of their components. + + +def test_top_readLock_count_gte_queries_count(collection): + """Test that readLock.count >= queries.count.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + list(collection.find()) + collection.update_one({"_id": 0}, {"$set": {"a": 99}}) + collection.delete_one({"_id": 4}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties( + ns_data, + {"readLock.count": Gte(ns_data["queries"]["count"])}, + msg="readLock.count should be >= queries.count", + raw_res=True, + ) + + +def test_top_readLock_time_gte_queries_time(collection): + """Test that readLock.time >= queries.time.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + list(collection.find()) + collection.update_one({"_id": 0}, {"$set": {"a": 99}}) + collection.delete_one({"_id": 4}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties( + ns_data, + {"readLock.time": Gte(ns_data["queries"]["time"])}, + msg="readLock.time should be >= queries.time", + raw_res=True, + ) + + +def test_top_writeLock_count_gte_insert_update_remove(collection): + """Test that writeLock.count >= insert.count + update.count + remove.count.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + list(collection.find()) + collection.update_one({"_id": 0}, {"$set": {"a": 99}}) + collection.delete_one({"_id": 4}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + write_sum = ns_data["insert"]["count"] + ns_data["update"]["count"] + ns_data["remove"]["count"] + assertProperties( + ns_data, + {"writeLock.count": Gte(write_sum)}, + msg="writeLock.count should be >= insert+update+remove count", + raw_res=True, + ) + + +def test_top_writeLock_time_gte_insert_update_remove(collection): + """Test that writeLock.time >= insert.time + update.time + remove.time.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + list(collection.find()) + collection.update_one({"_id": 0}, {"$set": {"a": 99}}) + collection.delete_one({"_id": 4}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + write_sum = ns_data["insert"]["time"] + ns_data["update"]["time"] + ns_data["remove"]["time"] + assertProperties( + ns_data, + {"writeLock.time": Gte(write_sum)}, + msg="writeLock.time should be >= insert+update+remove time", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_errors.py new file mode 100644 index 000000000..f7a2e50ba --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_errors.py @@ -0,0 +1,64 @@ +"""Tests for top command error conditions. + +Validates that invalid usages of top produce appropriate errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import COMMAND_NOT_FOUND_ERROR, UNAUTHORIZED_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + +# Property [Case Sensitivity]: command names are case-sensitive. +CASE_SENSITIVITY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="case_sensitive_Top", + command={"Top": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="'Top' (capitalized) should not be recognized", + ), + DiagnosticTestCase( + id="case_sensitive_TOP", + command={"TOP": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="'TOP' (all caps) should not be recognized", + ), + DiagnosticTestCase( + id="case_sensitive_tOP", + command={"tOP": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="'tOP' (mixed case) should not be recognized", + ), +] + +# Property [Admin Database Required]: top fails on non-admin database. +ADMIN_DB_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="non_admin_db", + command={"top": 1}, + use_admin=False, + error_code=UNAUTHORIZED_ERROR, + msg="top should fail on non-admin db", + ), +] + +ERROR_TESTS = CASE_SENSITIVITY_TESTS + ADMIN_DB_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_top_error_conditions(collection, test): + """Test that invalid top command usages produce appropriate errors.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_response_structure.py new file mode 100644 index 000000000..7389e7868 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/top/test_top_response_structure.py @@ -0,0 +1,116 @@ +"""Tests for top command response structure. + +Validates top-level response fields and per-collection event field structure. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gte, IsType + +pytestmark = pytest.mark.admin + + +# Property [Top-Level Fields]: top response contains totals, totals.note, and ok fields. +TOP_LEVEL_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="response_has_totals", + checks={"totals": IsType("object")}, + msg="'totals' field should be an object", + ), + DiagnosticTestCase( + id="response_has_note", + checks={"totals.note": Eq("all times in microseconds")}, + msg="'totals.note' should describe time units", + ), + DiagnosticTestCase( + id="response_has_ok", + checks={"ok": Eq(1.0)}, + msg="'ok' field should be 1.0", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TOP_LEVEL_TESTS)) +def test_top_response_top_level(collection, test): + """Test that top response contains expected top-level fields.""" + result = execute_admin_command(collection, {"top": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +# Property [Per-Collection Event Fields]: each namespace entry has 9 event fields with time/count. +EVENT_EXISTS_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id=f"event_{name}_exists", + checks={name: IsType("object")}, + msg=f"'{name}' event field should be an object", + ) + for name in [ + "total", + "readLock", + "writeLock", + "queries", + "getmore", + "insert", + "update", + "remove", + "commands", + ] +] + +EVENT_TIME_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id=f"event_{name}_time_gte_0", + checks={f"{name}.time": Gte(0)}, + msg=f"'{name}.time' should be >= 0", + ) + for name in [ + "total", + "readLock", + "writeLock", + "queries", + "getmore", + "insert", + "update", + "remove", + "commands", + ] +] + +EVENT_COUNT_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id=f"event_{name}_count_gte_0", + checks={f"{name}.count": Gte(0)}, + msg=f"'{name}.count' should be >= 0", + ) + for name in [ + "total", + "readLock", + "writeLock", + "queries", + "getmore", + "insert", + "update", + "remove", + "commands", + ] +] + +EVENT_FIELD_TESTS = EVENT_EXISTS_TESTS + EVENT_TIME_TESTS + EVENT_COUNT_TESTS + + +@pytest.mark.parametrize("test", pytest_params(EVENT_FIELD_TESTS)) +def test_top_event_field_structure(collection, test): + """Test that per-collection event fields have expected structure.""" + collection.insert_one({"_id": "event_structure_probe"}) + result = execute_admin_command(collection, {"top": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_data = result["totals"].get(ns) + if ns_data is None: + raise AssertionError(f"Namespace {ns} not found in top totals") + assertProperties(ns_data, test.checks, msg=test.msg, raw_res=True) From eef01e4a59996404cb9b4e426261da2ce3cd7a7e Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Thu, 2 Jul 2026 16:47:20 -0700 Subject: [PATCH 32/51] Add admin command tests for killOp (#637) Signed-off-by: Victor [C] Tsang --- .../commands/killOp/__init__.py | 0 .../test_killOp_bson_type_validation.py | 114 +++++++++++++++++ .../killOp/test_killOp_core_behavior.py | 90 ++++++++++++++ .../commands/killOp/test_killOp_errors.py | 117 ++++++++++++++++++ documentdb_tests/framework/error_codes.py | 1 + documentdb_tests/framework/test_constants.py | 3 + 6 files changed, 325 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/killOp/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_errors.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/killOp/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_bson_type_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_bson_type_validation.py new file mode 100644 index 000000000..c9b8b5f5b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_bson_type_validation.py @@ -0,0 +1,114 @@ +"""Tests for killOp BSON type validation.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertFailureCode, assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import NON_RUNNING_OP_ID + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +KILLOP_VALUE_PARAM = [ + BsonTypeTestCase( + id="killOp_value", + msg="killOp should accept all BSON types for the command field value", + keyword="killOp", + valid_types=list(BsonType), + requires={"op": NON_RUNNING_OP_ID}, + ), +] + +COMMENT_PARAM = [ + BsonTypeTestCase( + id="comment", + msg="killOp should accept all BSON types for the comment field", + keyword="comment", + valid_types=list(BsonType), + requires={"op": NON_RUNNING_OP_ID}, + ), +] + +OP_VALUE_PARAM = [ + BsonTypeTestCase( + id="op", + msg="op field should accept numeric types only", + keyword="op", + valid_types=[ + BsonType.INT, + BsonType.DOUBLE, + BsonType.LONG, + BsonType.DECIMAL, + ], + valid_inputs={ + BsonType.DOUBLE: 999999.0, + BsonType.LONG: Int64(999999), + BsonType.DECIMAL: Decimal128("999999"), + }, + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +# killOp and comment accept all BSON types, so they have acceptance cases only (no rejections). +KILLOP_ACCEPTANCE = generate_bson_acceptance_test_cases(KILLOP_VALUE_PARAM) +COMMENT_ACCEPTANCE = generate_bson_acceptance_test_cases(COMMENT_PARAM) +OP_ACCEPTANCE = generate_bson_acceptance_test_cases(OP_VALUE_PARAM) +OP_REJECTIONS = generate_bson_rejection_test_cases(OP_VALUE_PARAM) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", KILLOP_ACCEPTANCE) +def test_killOp_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test killOp accepts valid BSON types for the killOp command field.""" + result = execute_admin_command(collection, {"killOp": sample_value, "op": NON_RUNNING_OP_ID}) + assertProperties( + result, + {"ok": Eq(1.0)}, + msg=f"{spec.msg} (bson_type={bson_type.value})", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", COMMENT_ACCEPTANCE) +def test_killOp_comment_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test comment field accepts all BSON types and is consumed (not echoed back).""" + result = execute_admin_command( + collection, {"killOp": 1, "comment": sample_value, "op": NON_RUNNING_OP_ID} + ) + assertProperties( + result, + {"ok": Eq(1.0), "comment": NotExists()}, + msg=f"{spec.msg} (bson_type={bson_type.value})", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", OP_ACCEPTANCE) +def test_killOp_op_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test op field accepts the numeric BSON types.""" + result = execute_admin_command(collection, {"killOp": 1, "op": sample_value}) + assertProperties( + result, + {"ok": Eq(1.0)}, + msg=f"{spec.msg} (bson_type={bson_type.value})", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", OP_REJECTIONS) +def test_killOp_op_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test op field rejects non-numeric BSON types.""" + result = execute_admin_command(collection, {"killOp": 1, "op": sample_value}) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"killOp should reject {bson_type.value} for op field", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_core_behavior.py new file mode 100644 index 000000000..f04430aa7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_core_behavior.py @@ -0,0 +1,90 @@ +"""Tests for killOp command core behavior. + +Validates killOp response structure, behavior with non-existent operations, +and edge cases for opId values. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.test_constants import ( + INT32_MAX, + INT32_MIN, + INT32_ZERO, + NON_RUNNING_OP_ID, +) + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +def test_killOp_nonexistent_opid_returns_ok(collection): + """Test killOp with opId that does not correspond to any running operation returns ok:1.""" + result = execute_admin_command(collection, {"killOp": 1, "op": NON_RUNNING_OP_ID}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for non-existent opId", + ) + + +def test_killOp_op_int32_min(collection): + """Test killOp with minimum int32 opId returns ok:1.""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT32_MIN}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for INT32_MIN opId", + ) + + +def test_killOp_op_zero(collection): + """Test killOp with zero opId returns ok:1.""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT32_ZERO}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for opId 0", + ) + + +def test_killOp_op_int32_max(collection): + """Test killOp with maximum int32 opId returns ok:1.""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT32_MAX}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for INT32_MAX opId", + ) + + +def test_killOp_op_int32_max_as_double(collection): + """Test killOp accepts INT32_MAX as a whole-number double.""" + result = execute_admin_command(collection, {"killOp": 1, "op": float(INT32_MAX)}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for INT32_MAX as a double", + ) + + +def test_killOp_op_int32_min_as_double(collection): + """Test killOp accepts INT32_MIN as a whole-number double.""" + result = execute_admin_command(collection, {"killOp": 1, "op": float(INT32_MIN)}) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should return ok:1 for INT32_MIN as a double", + ) + + +def test_killOp_extra_unrecognized_field(collection): + """Test killOp with unrecognized top-level field is accepted (not rejected).""" + result = execute_admin_command( + collection, {"killOp": 1, "op": NON_RUNNING_OP_ID, "unknownField": 1} + ) + assertSuccessPartial( + result, + {"ok": 1.0, "info": "attempting to kill op"}, + msg="Should ignore unrecognized fields", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_errors.py new file mode 100644 index 000000000..43f3dca5b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/killOp/test_killOp_errors.py @@ -0,0 +1,117 @@ +"""Tests for killOp command error cases. + +Consolidates all failure-asserting test cases for the killOp command: +non-admin database rejection, missing op field, and invalid op values +(non-integral, or a valid integer outside the int32 range). +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + KILLOP_OPID_NOT_INT32_ERROR, + NO_SUCH_KEY_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.test_constants import ( + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MIN, + NON_RUNNING_OP_ID, +) + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +def test_killOp_op_fractional_double_rejected(collection): + """Test killOp rejects a fractional double op value.""" + result = execute_admin_command(collection, {"killOp": 1, "op": 3.14}) + assertFailureCode(result, BAD_VALUE_ERROR, msg="killOp should reject a non-integral op value") + + +def test_killOp_op_fractional_decimal_rejected(collection): + """Test killOp rejects a fractional decimal op value.""" + result = execute_admin_command(collection, {"killOp": 1, "op": Decimal128("3.14")}) + assertFailureCode(result, BAD_VALUE_ERROR, msg="killOp should reject a non-integral op value") + + +def test_killOp_op_nearly_int_double_rejected(collection): + """Test killOp rejects a double that is very close to but not an integer.""" + result = execute_admin_command(collection, {"killOp": 1, "op": 0.9999999999999999}) + assertFailureCode(result, BAD_VALUE_ERROR, msg="killOp should reject a non-integral op value") + + +def test_killOp_op_double_above_int32_max_rejected(collection): + """Test killOp rejects a double above the int32 range.""" + result = execute_admin_command(collection, {"killOp": 1, "op": float(INT32_OVERFLOW)}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_long_above_int32_max_rejected(collection): + """Test killOp rejects a long above the int32 range.""" + result = execute_admin_command(collection, {"killOp": 1, "op": Int64(INT32_OVERFLOW)}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_long_below_int32_min_rejected(collection): + """Test killOp rejects a long below the int32 range.""" + result = execute_admin_command(collection, {"killOp": 1, "op": Int64(INT32_UNDERFLOW)}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_int64_max_rejected(collection): + """Test killOp rejects INT64_MAX (outside the int32 range).""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT64_MAX}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_int64_min_rejected(collection): + """Test killOp rejects INT64_MIN (outside the int32 range).""" + result = execute_admin_command(collection, {"killOp": 1, "op": INT64_MIN}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_op_decimal_above_int32_max_rejected(collection): + """Test killOp rejects a decimal above the int32 range.""" + result = execute_admin_command(collection, {"killOp": 1, "op": Decimal128(str(INT32_OVERFLOW))}) + assertFailureCode( + result, + KILLOP_OPID_NOT_INT32_ERROR, + msg="killOp should reject an op value outside the int32 range", + ) + + +def test_killOp_on_non_admin_database_fails(collection): + """Test killOp run against non-admin database fails with error.""" + result = execute_command(collection, {"killOp": 1, "op": NON_RUNNING_OP_ID}) + assertFailureCode(result, UNAUTHORIZED_ERROR, msg="Should fail on non-admin database") + + +def test_killOp_missing_op_field(collection): + """Test killOp without op field fails with error.""" + result = execute_admin_command(collection, {"killOp": 1}) + assertFailureCode(result, NO_SUCH_KEY_ERROR, msg="Should fail without op field") diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 6353c21e2..187b41878 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -157,6 +157,7 @@ DATETOSTRING_YEAR_RANGE_ERROR = 18537 DATETOSTRING_MISSING_DATE_ERROR = 18628 DATETOSTRING_NON_OBJECT_ERROR = 18629 +KILLOP_OPID_NOT_INT32_ERROR = 26823 EMPTY_DB_NAME_ERROR = 28539 FILTER_NON_OBJECT_ARG_ERROR = 28646 FILTER_UNKNOWN_FIELD_ERROR = 28647 diff --git a/documentdb_tests/framework/test_constants.py b/documentdb_tests/framework/test_constants.py index 7e772c09c..02002c2aa 100644 --- a/documentdb_tests/framework/test_constants.py +++ b/documentdb_tests/framework/test_constants.py @@ -88,6 +88,9 @@ CLUSTERED_RECORD_ID_LIMIT_BYTES = 8 * 1024 * 1024 CAPPED_SIZE_LIMIT_BYTES = 2**50 +# An op id used by killOp tests +NON_RUNNING_OP_ID = 999999999 + # Int32 lists NUMERIC_INT32_NEGATIVE = [INT32_UNDERFLOW, INT32_MIN] NUMERIC_INT32_ZERO = [INT32_ZERO] From 09433fa90eb270d4e7c966d3527fbac14fdacc46 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 2 Jul 2026 17:05:58 -0700 Subject: [PATCH 33/51] Avoid scanning historical oplog in cluster-wide changeStream tests (#665) Signed-off-by: Daniel Frankcom --- .../changeStream/test_changeStream_early_start.py | 9 ++++++++- .../changeStream/test_changeStream_timestamp_boundary.py | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py index f0f53ebc0..901dcd8c7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_early_start.py @@ -129,8 +129,15 @@ def test_changeStream_early_start_cluster_scope( """Test $changeStream accepts an early startAtOperationTime on a cluster-wide stream.""" start = test_case.compute_start(_oldest_oplog_ts(collection)) spec = {"startAtOperationTime": start, "allChangesForCluster": True} + # batchSize 0 opens the stream without materializing historical events, whose + # resume tokens can exceed the BSON document size limit on a cluster-wide stream. result = execute_admin_command( collection, - change_stream_command(collection, pipeline=[{"$changeStream": spec}], aggregate=1), + change_stream_command( + collection, + pipeline=[{"$changeStream": spec}], + aggregate=1, + cursor={"batchSize": 0}, + ), ) assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py index e136e6009..8d41357e7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py +++ b/documentdb_tests/compatibility/tests/core/operator/system-stages/changeStream/test_changeStream_timestamp_boundary.py @@ -110,8 +110,15 @@ def test_changeStream_timestamp_boundary_cluster_scope( ) start = test_case.compute_start(base["operationTime"]) spec = {"startAtOperationTime": start, "allChangesForCluster": True} + # batchSize 0 opens the stream without materializing historical events, whose + # resume tokens can exceed the BSON document size limit on a cluster-wide stream. result = execute_admin_command( collection, - change_stream_command(collection, pipeline=[{"$changeStream": spec}], aggregate=1), + change_stream_command( + collection, + pipeline=[{"$changeStream": spec}], + aggregate=1, + cursor={"batchSize": 0}, + ), ) assertResult(result, expected=test_case.expected, msg=test_case.msg, raw_res=True) From 1ae6063e4be69860fe795f2eb081c2c8ca6e8299 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:22:35 -0700 Subject: [PATCH 34/51] Add `validate` tests (#639) \Signed-off-by: Alina (Xi) Li --- .../diagnostic/commands/validate/__init__.py | 0 .../test_validate_bool_param_coercion.py | 168 ++++++++++ ...test_validate_checkBSONConformance_type.py | 50 +++ .../validate/test_validate_core_behavior.py | 245 +++++++++++++++ .../validate/test_validate_edge_cases.py | 178 +++++++++++ .../validate/test_validate_error_cases.py | 283 +++++++++++++++++ .../validate/test_validate_indexes.py | 232 ++++++++++++++ .../validate/test_validate_metadata_type.py | 96 ++++++ .../commands/validate/test_validate_repair.py | 106 +++++++ .../test_validate_response_structure.py | 289 ++++++++++++++++++ .../diagnostic/utils/diagnostic_test_case.py | 13 +- documentdb_tests/framework/error_codes.py | 1 + documentdb_tests/framework/preconditions.py | 6 + 13 files changed, 1666 insertions(+), 1 deletion(-) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_bool_param_coercion.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_checkBSONConformance_type.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_error_cases.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_indexes.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_metadata_type.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_repair.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_bool_param_coercion.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_bool_param_coercion.py new file mode 100644 index 000000000..e180a2081 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_bool_param_coercion.py @@ -0,0 +1,168 @@ +"""Canonical BSON type coercion matrix for validate boolean parameters. + +Uses 'full' as the representative parameter. All boolean parameters +(full, metadata, checkBSONConformance, repair, fixMultikey) share the +same coercion logic; other per-parameter files contain only wiring tests. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +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 [Type Coercion]: validate accepts all BSON types for the full parameter via coercion. +ACCEPTED_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_true", + command={"full": True}, + checks={"ok": Eq(1.0)}, + msg="full should accept bool true", + ), + DiagnosticTestCase( + "bool_false", + command={"full": False}, + checks={"ok": Eq(1.0)}, + msg="full should accept bool false", + ), + DiagnosticTestCase( + "int32_1", + command={"full": 1}, + checks={"ok": Eq(1.0)}, + msg="full should accept int32 1 (coerces to true)", + ), + DiagnosticTestCase( + "int32_0", + command={"full": 0}, + checks={"ok": Eq(1.0)}, + msg="full should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "double_1", + command={"full": 1.0}, + checks={"ok": Eq(1.0)}, + msg="full should accept double 1.0 (coerces to true)", + ), + DiagnosticTestCase( + "double_0", + command={"full": 0.0}, + checks={"ok": Eq(1.0)}, + msg="full should accept double 0.0 (coerces to false)", + ), + DiagnosticTestCase( + "int64_1", + command={"full": Int64(1)}, + checks={"ok": Eq(1.0)}, + msg="full should accept Int64(1) (coerces to true)", + ), + DiagnosticTestCase( + "int64_0", + command={"full": Int64(0)}, + checks={"ok": Eq(1.0)}, + msg="full should accept Int64(0) (coerces to false)", + ), + DiagnosticTestCase( + "decimal128_1", + command={"full": Decimal128("1")}, + checks={"ok": Eq(1.0)}, + msg="full should accept Decimal128('1') (coerces to true)", + ), + DiagnosticTestCase( + "decimal128_0", + command={"full": Decimal128("0")}, + checks={"ok": Eq(1.0)}, + msg="full should accept Decimal128('0') (coerces to false)", + ), + DiagnosticTestCase( + "null", + command={"full": None}, + checks={"ok": Eq(1.0)}, + msg="full should accept null (treated as omitted/false)", + ), + DiagnosticTestCase( + "string", + command={"full": "true"}, + checks={"ok": Eq(1.0)}, + msg="full should accept string (coerces to truthy)", + ), + DiagnosticTestCase( + "object", + command={"full": {}}, + checks={"ok": Eq(1.0)}, + msg="full should accept object (coerces to truthy)", + ), + DiagnosticTestCase( + "array", + command={"full": []}, + checks={"ok": Eq(1.0)}, + msg="full should accept array (coerces to truthy)", + ), + DiagnosticTestCase( + "binary", + command={"full": Binary(b"")}, + checks={"ok": Eq(1.0)}, + msg="full should accept Binary (coerces to truthy)", + ), + DiagnosticTestCase( + "objectid", + command={"full": ObjectId()}, + checks={"ok": Eq(1.0)}, + msg="full should accept ObjectId (coerces to truthy)", + ), + DiagnosticTestCase( + "datetime", + command={"full": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + checks={"ok": Eq(1.0)}, + msg="full should accept datetime (coerces to truthy)", + ), + DiagnosticTestCase( + "regex", + command={"full": Regex(".*")}, + checks={"ok": Eq(1.0)}, + msg="full should accept Regex (coerces to truthy)", + ), + DiagnosticTestCase( + "timestamp", + command={"full": Timestamp(0, 0)}, + checks={"ok": Eq(1.0)}, + msg="full should accept Timestamp (coerces to truthy)", + ), + DiagnosticTestCase( + "code", + command={"full": Code("function(){}")}, + checks={"ok": Eq(1.0)}, + msg="full should accept JavaScript Code (coerces to truthy)", + ), + DiagnosticTestCase( + "minkey", + command={"full": MinKey()}, + checks={"ok": Eq(1.0)}, + msg="full should accept MinKey (coerces to truthy)", + ), + DiagnosticTestCase( + "maxkey", + command={"full": MaxKey()}, + checks={"ok": Eq(1.0)}, + msg="full should accept MaxKey (coerces to truthy)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ACCEPTED_TYPE_TESTS)) +def test_validate_full_accepted_types(collection, test): + """Test that validate accepts all BSON types for the full parameter.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"validate": collection.name, **test.command}, + ) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_checkBSONConformance_type.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_checkBSONConformance_type.py new file mode 100644 index 000000000..e45f5fcf4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_checkBSONConformance_type.py @@ -0,0 +1,50 @@ +"""Wiring tests for validate command 'checkBSONConformance' parameter type coercion. + +Confirms checkBSONConformance uses the same boolean coercion as other params. +The full BSON type matrix is in test_validate_bool_param_coercion.py. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +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 [Type Coercion Wiring]: checkBSONConformance delegates to shared boolean coercion. +WIRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_true", + command={"checkBSONConformance": True}, + checks={"ok": Eq(1.0)}, + msg="checkBSONConformance should accept bool true", + ), + DiagnosticTestCase( + "int32_0", + command={"checkBSONConformance": 0}, + checks={"ok": Eq(1.0)}, + msg="checkBSONConformance should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "string", + command={"checkBSONConformance": "true"}, + checks={"ok": Eq(1.0)}, + msg="checkBSONConformance should accept string (coerces to truthy)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WIRING_TESTS)) +def test_validate_checkBSONConformance_accepted_types(collection, test): + """Test that checkBSONConformance uses shared boolean coercion.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"validate": collection.name, **test.command}, + ) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_core_behavior.py new file mode 100644 index 000000000..07302f948 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_core_behavior.py @@ -0,0 +1,245 @@ +"""Tests for validate command core behavior. + +Validates basic functionality, counts, consistency across calls, comment parameter, +behavior on different collection types (capped, timeseries, clustered), and valid +option combinations. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, + bind_collection, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Core Behavior]: validate returns expected results for populated +# and empty collections and supports common parameters. +CORE_BEHAVIOR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "populated_collection", + setup=[{"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(5)}, + msg="validate should return valid: true with correct nrecords for a populated collection", + ), + DiagnosticTestCase( + "empty_collection", + setup=[{"create": ""}], + checks={ + "ok": Eq(1.0), + "valid": Eq(True), + "nrecords": Eq(0), + "nIndexes": Eq(1), + }, + msg="validate should return nrecords: 0 and nIndexes: 1 for an empty collection", + ), + DiagnosticTestCase( + "after_insert_and_delete_all", + setup=[ + {"insert": "", "documents": [{"_id": i} for i in range(5)]}, + {"delete": "", "deletes": [{"q": {}, "limit": 0}]}, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(0)}, + msg="validate should return nrecords: 0 after deleting all documents", + ), + DiagnosticTestCase( + "after_dropping_indexes", + setup=[ + {"insert": "", "documents": [{"_id": 1, "x": 1}]}, + {"createIndexes": "", "indexes": [{"key": {"x": 1}, "name": "x_1"}]}, + {"dropIndexes": "", "index": "*"}, + ], + checks={"ok": Eq(1.0), "nIndexes": Eq(1)}, + msg="validate should return nIndexes: 1 after dropping secondary indexes", + ), + DiagnosticTestCase( + "with_comment", + setup=[{"insert": "", "documents": [{"_id": 1}]}], + command={"comment": "test comment"}, + checks={"ok": Eq(1.0)}, + msg="validate should succeed with comment parameter", + ), + DiagnosticTestCase( + "unrecognized_field_ignored", + setup=[{"insert": "", "documents": [{"_id": 1}]}], + command={"unknownField": 1}, + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should ignore unrecognized fields and succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CORE_BEHAVIOR_TESTS)) +def test_validate_core_behavior(collection, test): + """Test validate core behavior with various collection states.""" + for cmd in test.setup: + execute_command(collection, bind_collection(cmd, collection.name)) + cmd = {"validate": collection.name} + if test.command: + cmd.update(test.command) + result = execute_command(collection, cmd) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_validate_consistent_across_calls(collection): + """Test validate returns consistent results across multiple calls.""" + collection.insert_many([{"_id": i, "x": i} for i in range(5)]) + result1 = execute_command(collection, {"validate": collection.name}) + result2 = execute_command(collection, {"validate": collection.name}) + assertProperties( + result1, + { + "nrecords": Eq(result2["nrecords"]), + "nIndexes": Eq(result2["nIndexes"]), + "valid": Eq(result2["valid"]), + }, + raw_res=True, + msg="validate should return identical key fields across consecutive calls", + ) + + +def test_validate_reflects_modifications(collection): + """Test validate reflects modifications between calls.""" + collection.insert_many([{"_id": i} for i in range(3)]) + execute_command(collection, {"validate": collection.name}) + collection.insert_many([{"_id": i} for i in range(3, 8)]) + result2 = execute_command(collection, {"validate": collection.name}) + assertProperties( + result2, + {"nrecords": Eq(8)}, + raw_res=True, + msg="validate should reflect updated nrecords after additional inserts", + ) + + +def test_validate_capped_collection(database_client, collection): + """Test validate on a capped collection succeeds.""" + coll_name = f"{collection.name}_capped" + database_client.create_collection(coll_name, capped=True, size=1_048_576) + coll = database_client[coll_name] + coll.insert_one({"_id": 1, "x": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0, "valid": True}, + msg="validate should succeed on a capped collection", + ) + + +def test_validate_timeseries_collection(database_client, collection): + """Test validate on a time series collection succeeds.""" + coll_name = f"{collection.name}_timeseries" + database_client.create_collection( + coll_name, + timeseries={"timeField": "ts", "metaField": "meta"}, + ) + coll = database_client[coll_name] + coll.insert_one({"ts": datetime(2024, 1, 1, tzinfo=timezone.utc), "meta": "a", "v": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="validate should succeed on a timeseries collection", + ) + + +def test_validate_clustered_collection(database_client, collection): + """Test validate on a clustered collection succeeds.""" + coll_name = f"{collection.name}_clustered" + database_client.command( + "create", + coll_name, + clusteredIndex={"key": {"_id": 1}, "unique": True}, + ) + coll = database_client[coll_name] + coll.insert_one({"_id": 1, "x": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="validate should succeed on a clustered collection", + ) + + +# Property [Valid Options]: validate succeeds with each option individually +# and with compatible multi-option combinations. +VALID_OPTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "all_options_false", + command={"full": False, "repair": False, "metadata": False, "checkBSONConformance": False}, + checks={"ok": Eq(1.0)}, + msg="validate should succeed with all options set to false explicitly", + ), + DiagnosticTestCase( + "full_true", + command={"full": True}, + checks={"ok": Eq(1.0)}, + msg="validate with full: true should succeed", + ), + DiagnosticTestCase( + "checkBSONConformance_true", + command={"checkBSONConformance": True}, + checks={"ok": Eq(1.0)}, + msg="validate with checkBSONConformance: true should succeed", + ), + DiagnosticTestCase( + "full_with_checkBSONConformance", + command={"full": True, "checkBSONConformance": True}, + checks={"ok": Eq(1.0)}, + msg="validate with full: true and checkBSONConformance: true should succeed", + ), + DiagnosticTestCase( + "metadata_true", + command={"metadata": True}, + checks={"ok": Eq(1.0)}, + msg="validate with metadata: true should succeed", + ), +] + + +# Property [Valid Repair Options]: validate succeeds with repair/fixMultikey +# options (standalone only). +VALID_REPAIR_OPTION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "fixMultikey_true", + command={"fixMultikey": True}, + checks={"ok": Eq(1.0)}, + msg="validate with fixMultikey: true should succeed", + ), + DiagnosticTestCase( + "repair_true", + command={"repair": True}, + checks={"ok": Eq(1.0)}, + msg="validate with repair: true should succeed", + ), + DiagnosticTestCase( + "repair_with_fixMultikey", + command={"repair": True, "fixMultikey": True}, + checks={"ok": Eq(1.0)}, + msg="validate with repair: true and fixMultikey: true should succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VALID_OPTION_TESTS)) +def test_validate_valid_options(collection, test): + """Test that validate succeeds with valid option values.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name, **test.command}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +@pytest.mark.requires(validate_repair=True) +@pytest.mark.parametrize("test", pytest_params(VALID_REPAIR_OPTION_TESTS)) +def test_validate_valid_repair_options(collection, test): + """Test that validate succeeds with repair/fixMultikey options on standalone.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name, **test.command}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_edge_cases.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_edge_cases.py new file mode 100644 index 000000000..d7674fad6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_edge_cases.py @@ -0,0 +1,178 @@ +"""Tests for validate command edge cases. + +Validates behavior with collection name edge cases, document variety, and +large collections. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, + bind_collection, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Document Variety]: validate succeeds for collections with diverse +# document shapes, sizes, and BSON types. +DOCUMENT_VARIETY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "large_document_count", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "x": i} for i in range(1_000)], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(1_000)}, + msg="validate should report correct nrecords for large document count", + ), + DiagnosticTestCase( + "all_bson_types", + setup=[ + { + "insert": "", + "documents": [ + { + "_id": 1, + "double_val": 3.14, + "string_val": "hello", + "object_val": {"nested": 1}, + "array_val": [1, 2, 3], + "binary_val": Binary(b"data"), + "objectid_val": ObjectId(), + "bool_val": True, + "date_val": datetime(2024, 1, 1, tzinfo=timezone.utc), + "null_val": None, + "regex_val": Regex("test"), + "int32_val": 42, + "timestamp_val": Timestamp(1, 1), + "int64_val": Int64(123_456_789), + "decimal128_val": Decimal128("1.23"), + "minkey_val": MinKey(), + "maxkey_val": MaxKey(), + } + ], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should return valid: true for a document with all BSON types", + ), + DiagnosticTestCase( + "deeply_nested_document", + setup=[ + { + "insert": "", + "documents": [ + { + "_id": 1, + "level_0": { + "level_1": { + "level_2": { + "level_3": { + "level_4": { + "level_5": { + "level_6": { + "level_7": { + "level_8": {"level_9": {"value": "deep"}} + } + } + } + } + } + } + } + }, + } + ], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should return valid: true for a deeply nested document", + ), + DiagnosticTestCase( + "documents_with_arrays", + setup=[ + { + "insert": "", + "documents": [ + {"_id": 1, "arr": []}, + {"_id": 2, "arr": [1, 2, 3]}, + {"_id": 3, "arr": list(range(100))}, + ], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(3)}, + msg="validate should return valid: true for documents with arrays", + ), + DiagnosticTestCase( + "documents_with_binary_data", + setup=[ + { + "insert": "", + "documents": [ + {"_id": 1, "data": Binary(b"small")}, + {"_id": 2, "data": Binary(b"\x00" * 1_024)}, + ], + } + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nrecords": Eq(2)}, + msg="validate should return valid: true for documents with binary data", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DOCUMENT_VARIETY_TESTS)) +def test_validate_document_variety(collection, test): + """Test validate with diverse document shapes and index counts.""" + for cmd in test.setup: + execute_command(collection, bind_collection(cmd, collection.name)) + result = execute_command(collection, {"validate": collection.name}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_validate_unicode_collection_name(database_client, collection): + """Test validate succeeds with a unicode collection name.""" + coll_name = f"{collection.name}_\u00e9\u00e8\u00ea" + coll = database_client[coll_name] + coll.insert_one({"_id": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="validate should succeed with unicode collection name", + ) + + +def test_validate_numeric_looking_collection_name(database_client, collection): + """Test validate succeeds with a numeric-looking collection name.""" + coll_name = f"{collection.name}_12345" + coll = database_client[coll_name] + coll.insert_one({"_id": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="validate should succeed with numeric-looking collection name", + ) + + +def test_validate_long_collection_name(database_client, collection): + """Test validate with a collection name at the max namespace length.""" + db_name = database_client.name + # Namespace is "db.coll" so max coll length is 255 - len(db_name) - 1. + max_coll_len = 255 - len(db_name) - 1 + coll_name = f"{collection.name}_" + "a" * (max_coll_len - len(collection.name) - 1) + coll = database_client[coll_name] + coll.insert_one({"_id": 1}) + result = execute_command(coll, {"validate": coll.name}) + assertSuccessPartial( + result, {"ok": 1.0}, msg="validate should succeed with a long collection name" + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_error_cases.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_error_cases.py new file mode 100644 index 000000000..4847bdd01 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_error_cases.py @@ -0,0 +1,283 @@ +"""Tests for validate command error cases. + +Validates that validate returns expected errors for non-string collection name +types, invalid string values, non-existent collections, views, invalid option +combinations, and truthy background values on standalone. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_SUPPORTED_ERROR, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + INVALID_NAMESPACE_ERROR, + INVALID_OPTIONS_ERROR, + NAMESPACE_NOT_FOUND_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Type Rejection]: validate rejects all non-string BSON types for the collection name. +INVALID_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "double", + command={"validate": 1.0}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject double for collection name", + ), + DiagnosticTestCase( + "int32", + command={"validate": 1}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject int32 for collection name", + ), + DiagnosticTestCase( + "int64", + command={"validate": Int64(1)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject int64 for collection name", + ), + DiagnosticTestCase( + "decimal128", + command={"validate": Decimal128("1")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject Decimal128 for collection name", + ), + DiagnosticTestCase( + "bool_true", + command={"validate": True}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject bool true for collection name", + ), + DiagnosticTestCase( + "bool_false", + command={"validate": False}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject bool false for collection name", + ), + DiagnosticTestCase( + "null", + command={"validate": None}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject null for collection name", + ), + DiagnosticTestCase( + "object", + command={"validate": {"a": 1}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject object for collection name", + ), + DiagnosticTestCase( + "empty_object", + command={"validate": {}}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject empty object for collection name", + ), + DiagnosticTestCase( + "array", + command={"validate": [1, 2]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject array for collection name", + ), + DiagnosticTestCase( + "empty_array", + command={"validate": []}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject empty array for collection name", + ), + DiagnosticTestCase( + "binary", + command={"validate": Binary(b"data")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject Binary for collection name", + ), + DiagnosticTestCase( + "objectid", + command={"validate": ObjectId()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject ObjectId for collection name", + ), + DiagnosticTestCase( + "datetime", + command={"validate": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject datetime for collection name", + ), + DiagnosticTestCase( + "regex", + command={"validate": Regex(".*")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject Regex for collection name", + ), + DiagnosticTestCase( + "timestamp", + command={"validate": Timestamp(0, 0)}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject Timestamp for collection name", + ), + DiagnosticTestCase( + "code", + command={"validate": Code("function(){}")}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject JavaScript Code for collection name", + ), + DiagnosticTestCase( + "minkey", + command={"validate": MinKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject MinKey for collection name", + ), + DiagnosticTestCase( + "maxkey", + command={"validate": MaxKey()}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject MaxKey for collection name", + ), +] + + +# Property [Invalid String Values]: validate rejects invalid string values for collection name. +INVALID_STRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "empty_string", + command={"validate": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="validate should reject empty string for collection name", + ), + DiagnosticTestCase( + "dollar_prefix", + command={"validate": "$invalid"}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="validate should return NamespaceNotFound for dollar-prefixed collection name", + ), +] + + +INVALID_ARGUMENT_TESTS = INVALID_TYPE_TESTS + INVALID_STRING_TESTS + + +@pytest.mark.parametrize("test", pytest_params(INVALID_ARGUMENT_TESTS)) +def test_validate_rejects_invalid_arguments(collection, test): + """Test that validate rejects non-string types and invalid string values.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Property [Non-Existent Collection]: validate returns NamespaceNotFound for +# a collection that does not exist. +NON_EXISTENT_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "non_existent_collection", + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="validate should return NamespaceNotFound for a non-existent collection", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NON_EXISTENT_TESTS)) +def test_validate_non_existent(collection, test): + """Test validate on non-existent collections returns expected error.""" + result = execute_command(collection, {"validate": f"{collection.name}_nonexistent_xyz"}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Property [View Rejection]: validate rejects views. +def test_validate_view_rejected(database_client, collection): + """Test validate on a view returns CommandNotSupportedOnView error.""" + source_name = f"{collection.name}_view_source" + view_name = f"{collection.name}_view" + database_client.create_collection(source_name) + database_client[source_name].insert_one({"_id": 1}) + database_client.command("create", view_name, viewOn=source_name, pipeline=[]) + coll = database_client[view_name] + result = execute_command(coll, {"validate": coll.name}) + assertFailureCode( + result, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="validate should reject views", + ) + + +# Property [Invalid Combinations]: validate rejects incompatible option combinations. +INVALID_COMBINATION_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "metadata_with_full", + command={"metadata": True, "full": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with metadata: true and full: true", + ), + DiagnosticTestCase( + "metadata_with_repair", + command={"metadata": True, "repair": True, "fixMultikey": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with metadata: true and repair: true", + ), + DiagnosticTestCase( + "metadata_with_checkBSONConformance", + command={"metadata": True, "checkBSONConformance": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with metadata: true and checkBSONConformance: true", + ), + DiagnosticTestCase( + "checkBSONConformance_with_repair", + command={"checkBSONConformance": True, "repair": True, "fixMultikey": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with checkBSONConformance: true and repair: true", + ), + DiagnosticTestCase( + "metadata_with_background", + command={"metadata": True, "background": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="validate should error with metadata: true and background: true", + ), +] + + +# Property [Truthy Standalone Error]: validate rejects truthy background +# values on standalone mode. +TRUTHY_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_true", + command={"background": True}, + error_code=COMMAND_NOT_SUPPORTED_ERROR, + msg="background: true not supported on standalone", + ), + DiagnosticTestCase( + "int32_1", + command={"background": 1}, + error_code=COMMAND_NOT_SUPPORTED_ERROR, + msg="background: int 1 (truthy) not supported on standalone", + ), + DiagnosticTestCase( + "string", + command={"background": "true"}, + error_code=COMMAND_NOT_SUPPORTED_ERROR, + msg="background: string (truthy) not supported on standalone", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_COMBINATION_TESTS)) +def test_validate_option_errors(collection, test): + """Test that validate errors on invalid option combinations.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name, **test.command}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +@pytest.mark.requires(validate_repair=True) +@pytest.mark.parametrize("test", pytest_params(TRUTHY_TYPE_TESTS)) +def test_validate_background_truthy_rejected(collection, test): + """Test that validate rejects truthy background values on standalone.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name, **test.command}) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_indexes.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_indexes.py new file mode 100644 index 000000000..ae027bef0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_indexes.py @@ -0,0 +1,232 @@ +"""Tests for validate command with various index types. + +Validates that validate succeeds and correctly reports on collections with +different index types including unique, sparse, TTL, text, 2dsphere, hashed, +wildcard, compound, and partial filter indexes. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, + bind_collection, +) +from documentdb_tests.framework.assertions import assertProperties +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 [Index Types]: validate succeeds and reports correct nIndexes for +# collections with various index types. +INDEX_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "unique_index", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + { + "createIndexes": "", + "indexes": [{"key": {"x": 1}, "name": "x_1", "unique": True}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed and report unique index", + ), + DiagnosticTestCase( + "sparse_index", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "x": i} for i in range(5)] + + [{"_id": i} for i in range(5, 10)], + }, + { + "createIndexes": "", + "indexes": [{"key": {"x": 1}, "name": "x_1_sparse", "sparse": True}], + }, + ], + checks={ + "ok": Eq(1.0), + "valid": Eq(True), + "nrecords": Eq(10), + "nIndexes": Eq(2), + }, + msg="validate should succeed with sparse index", + ), + DiagnosticTestCase( + "ttl_index", + setup=[ + { + "insert": "", + "documents": [ + { + "_id": 1, + "created": datetime(2024, 1, 1, tzinfo=timezone.utc), + } + ], + }, + { + "createIndexes": "", + "indexes": [ + { + "key": {"created": 1}, + "name": "created_ttl", + "expireAfterSeconds": 3600, + } + ], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed and report TTL index", + ), + DiagnosticTestCase( + "text_index", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "content": f"document text {i}"} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [{"key": {"content": "text"}, "name": "content_text"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should succeed with text index", + ), + DiagnosticTestCase( + "2dsphere_index", + setup=[ + { + "insert": "", + "documents": [ + { + "_id": 1, + "location": { + "type": "Point", + "coordinates": [0.0, 0.0], + }, + } + ], + }, + { + "createIndexes": "", + "indexes": [{"key": {"location": "2dsphere"}, "name": "location_2dsphere"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should succeed with 2dsphere index", + ), + DiagnosticTestCase( + "hashed_index", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + { + "createIndexes": "", + "indexes": [{"key": {"x": "hashed"}, "name": "x_hashed"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed with hashed index", + ), + DiagnosticTestCase( + "wildcard_index", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "a": i, "b": str(i)} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [{"key": {"$**": 1}, "name": "wildcard"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True)}, + msg="validate should succeed with wildcard index", + ), + DiagnosticTestCase( + "compound_index", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "a": i, "b": -i} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [{"key": {"a": 1, "b": -1}, "name": "a_1_b_neg1"}], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed and report compound index", + ), + DiagnosticTestCase( + "partial_filter_index", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(10)]}, + { + "createIndexes": "", + "indexes": [ + { + "key": {"x": 1}, + "name": "x_partial", + "partialFilterExpression": {"x": {"$gt": 4}}, + } + ], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(2)}, + msg="validate should succeed with partial filter index", + ), + DiagnosticTestCase( + "multiple_indexes", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "a": i, "b": str(i)} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [ + {"key": {"a": 1}, "name": "a_unique", "unique": True}, + {"key": {"b": 1}, "name": "b_1"}, + {"key": {"a": 1, "b": 1}, "name": "a_1_b_1"}, + ], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(4)}, + msg="validate should report all 4 indexes (_id + 3 secondary)", + ), + DiagnosticTestCase( + "many_indexes", + setup=[ + { + "insert": "", + "documents": [{"_id": i, "a": i, "b": i, "c": i, "d": i, "e": i} for i in range(5)], + }, + { + "createIndexes": "", + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + {"key": {"c": 1}, "name": "c_1"}, + {"key": {"d": 1}, "name": "d_1"}, + {"key": {"e": 1}, "name": "e_1"}, + ], + }, + ], + checks={"ok": Eq(1.0), "valid": Eq(True), "nIndexes": Eq(6)}, + msg="validate should report nIndexes: 6 with 5 secondary indexes", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INDEX_TYPE_TESTS)) +def test_validate_index_types(collection, test): + """Test validate with various index types.""" + for cmd in test.setup: + execute_command(collection, bind_collection(cmd, collection.name)) + result = execute_command(collection, {"validate": collection.name}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_metadata_type.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_metadata_type.py new file mode 100644 index 000000000..4f183e395 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_metadata_type.py @@ -0,0 +1,96 @@ +"""Wiring tests for validate 'metadata' and 'background' parameter type coercion. + +Confirms metadata uses the same boolean coercion as other params (wiring only). +The full BSON type matrix is in test_validate_bool_param_coercion.py. +Also tests that the background parameter accepts falsy BSON types. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +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 [Type Coercion Wiring]: metadata delegates to shared boolean coercion. +METADATA_WIRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_true", + command={"metadata": True}, + checks={"ok": Eq(1.0)}, + msg="metadata should accept bool true", + ), + DiagnosticTestCase( + "int32_0", + command={"metadata": 0}, + checks={"ok": Eq(1.0)}, + msg="metadata should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "string", + command={"metadata": "true"}, + checks={"ok": Eq(1.0)}, + msg="metadata should accept string (coerces to truthy)", + ), +] + + +# Property [Falsy Type Acceptance]: validate accepts falsy BSON types for the background parameter. +FALSY_TYPE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "bool_false", + command={"background": False}, + checks={"ok": Eq(1.0)}, + msg="background should accept bool false", + ), + DiagnosticTestCase( + "int32_0", + command={"background": 0}, + checks={"ok": Eq(1.0)}, + msg="background should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "double_0", + command={"background": 0.0}, + checks={"ok": Eq(1.0)}, + msg="background should accept double 0.0 (coerces to false)", + ), + DiagnosticTestCase( + "int64_0", + command={"background": Int64(0)}, + checks={"ok": Eq(1.0)}, + msg="background should accept Int64(0) (coerces to false)", + ), + DiagnosticTestCase( + "decimal128_0", + command={"background": Decimal128("0")}, + checks={"ok": Eq(1.0)}, + msg="background should accept Decimal128('0') (coerces to false)", + ), + DiagnosticTestCase( + "null", + command={"background": None}, + checks={"ok": Eq(1.0)}, + msg="background should accept null (treated as omitted/false)", + ), +] + + +METADATA_AND_BACKGROUND_TESTS = METADATA_WIRING_TESTS + FALSY_TYPE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(METADATA_AND_BACKGROUND_TESTS)) +def test_validate_metadata_and_background_types(collection, test): + """Test type coercion for metadata and background parameters.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"validate": collection.name, **test.command}, + ) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_repair.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_repair.py new file mode 100644 index 000000000..eb9531825 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_repair.py @@ -0,0 +1,106 @@ +"""Wiring tests for validate command repair and fixMultikey options. + +Confirms repair and fixMultikey use the same boolean coercion as other params +(wiring only). The full BSON type matrix is in test_validate_bool_param_coercion.py. +Also verifies repairMode values for different repair configurations. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.requires(validate_repair=True) + +# Property [Type Coercion Wiring]: repair delegates to shared boolean coercion. +REPAIR_WIRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "repair_bool_true", + command={"repair": True}, + checks={"ok": Eq(1.0)}, + msg="repair should accept bool true", + ), + DiagnosticTestCase( + "repair_int32_0", + command={"repair": 0}, + checks={"ok": Eq(1.0)}, + msg="repair should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "repair_string", + command={"repair": "true"}, + checks={"ok": Eq(1.0)}, + msg="repair should accept string (coerces to truthy)", + ), +] + +# Property [Type Coercion Wiring]: fixMultikey delegates to shared boolean coercion. +FIXMULTIKEY_WIRING_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "fixMultikey_bool_true", + command={"fixMultikey": True}, + checks={"ok": Eq(1.0)}, + msg="fixMultikey should accept bool true", + ), + DiagnosticTestCase( + "fixMultikey_int32_0", + command={"fixMultikey": 0}, + checks={"ok": Eq(1.0)}, + msg="fixMultikey should accept int32 0 (coerces to false)", + ), + DiagnosticTestCase( + "fixMultikey_string", + command={"fixMultikey": "true"}, + checks={"ok": Eq(1.0)}, + msg="fixMultikey should accept string (coerces to truthy)", + ), +] + +# Property [Repair Mode]: validate returns correct repairMode for different configurations. +REPAIR_MODE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "no_repair_mode_none", + command={}, + checks={"ok": Eq(1.0), "repairMode": Eq("None"), "repaired": Eq(False)}, + msg="validate should return repairMode: 'None' with no repair options", + ), + DiagnosticTestCase( + "repair_and_fixMultikey_mode_fix_errors", + command={"repair": True, "fixMultikey": True}, + checks={"ok": Eq(1.0), "repairMode": Eq("FixErrors"), "repaired": Eq(False)}, + msg="validate with repair+fixMultikey should return repairMode: 'FixErrors'", + ), + DiagnosticTestCase( + "fixMultikey_alone_mode_adjust", + command={"fixMultikey": True}, + checks={"ok": Eq(1.0), "repairMode": Eq("AdjustMultikey"), "repaired": Eq(False)}, + msg="validate with fixMultikey alone should return repairMode: 'AdjustMultikey'", + ), + DiagnosticTestCase( + "repair_alone_mode_fix_errors", + command={"repair": True}, + checks={"ok": Eq(1.0), "repairMode": Eq("FixErrors"), "repaired": Eq(False)}, + msg="validate with repair alone should return repairMode: 'FixErrors'", + ), +] + + +REPAIR_AND_FIXMULTIKEY_TESTS = REPAIR_WIRING_TESTS + FIXMULTIKEY_WIRING_TESTS + REPAIR_MODE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(REPAIR_AND_FIXMULTIKEY_TESTS)) +def test_validate_repair_and_fixMultikey(collection, test): + """Test repair/fixMultikey type coercion and repairMode values.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"validate": collection.name, **test.command}, + ) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_response_structure.py new file mode 100644 index 000000000..f7fb62676 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/validate/test_validate_response_structure.py @@ -0,0 +1,289 @@ +"""Tests for validate command response structure. + +Validates presence, types, and values of response fields for healthy collections, +including keysPerIndex/indexDetails structure and full/metadata mode responses. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, + bind_collection, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, Gte, IsType, NotExists + +# Property [Response Structure]: validate returns expected field types and values for +# healthy collections. +PROPERTY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "ok_is_1", + checks={"ok": Eq(1.0)}, + msg="validate should return ok: 1.0", + ), + DiagnosticTestCase( + "ns_is_string", + checks={"ns": IsType("string")}, + msg="validate should return ns as a string", + ), + DiagnosticTestCase( + "nInvalidDocuments_is_int", + checks={"nInvalidDocuments": IsType("int")}, + msg="validate should return nInvalidDocuments as an int", + ), + DiagnosticTestCase( + "nNonCompliantDocuments_is_int", + checks={"nNonCompliantDocuments": IsType("int")}, + msg="validate should return nNonCompliantDocuments as an int", + ), + DiagnosticTestCase( + "nrecords_is_int", + checks={"nrecords": IsType("int")}, + msg="validate should return nrecords as an int", + ), + DiagnosticTestCase( + "nIndexes_is_int", + checks={"nIndexes": IsType("int")}, + msg="validate should return nIndexes as an int", + ), + DiagnosticTestCase( + "keysPerIndex_is_object", + checks={"keysPerIndex": IsType("object")}, + msg="validate should return keysPerIndex as an object", + ), + DiagnosticTestCase( + "indexDetails_is_object", + checks={"indexDetails": IsType("object")}, + msg="validate should return indexDetails as an object", + ), + DiagnosticTestCase( + "valid_is_bool", + checks={"valid": IsType("bool")}, + msg="validate should return valid as a bool", + ), + DiagnosticTestCase( + "repaired_is_bool", + checks={"repaired": IsType("bool")}, + msg="validate should return repaired as a bool", + ), + DiagnosticTestCase( + "warnings_is_array", + checks={"warnings": IsType("array")}, + msg="validate should return warnings as an array", + ), + DiagnosticTestCase( + "errors_is_array", + checks={"errors": IsType("array")}, + msg="validate should return errors as an array", + ), + DiagnosticTestCase( + "extraIndexEntries_is_array", + checks={"extraIndexEntries": IsType("array")}, + msg="validate should return extraIndexEntries as an array", + ), + DiagnosticTestCase( + "missingIndexEntries_is_array", + checks={"missingIndexEntries": IsType("array")}, + msg="validate should return missingIndexEntries as an array", + ), + DiagnosticTestCase( + "corruptRecords_is_array", + checks={"corruptRecords": IsType("array")}, + msg="validate should return corruptRecords as an array", + ), + DiagnosticTestCase( + "uuid_exists", + checks={"uuid": Exists()}, + msg="validate should return uuid field", + ), + DiagnosticTestCase( + "nInvalidDocuments_zero_healthy", + checks={"nInvalidDocuments": Eq(0)}, + msg="validate should return nInvalidDocuments: 0 for a healthy collection", + ), + DiagnosticTestCase( + "nNonCompliantDocuments_zero_healthy", + checks={"nNonCompliantDocuments": Eq(0)}, + msg="validate should return nNonCompliantDocuments: 0 for a healthy collection", + ), + DiagnosticTestCase( + "valid_true_healthy", + checks={"valid": Eq(True)}, + msg="validate should return valid: true for a healthy collection", + ), + DiagnosticTestCase( + "warnings_empty_healthy", + checks={"warnings": Eq([])}, + msg="validate should return empty warnings for a healthy collection", + ), + DiagnosticTestCase( + "errors_empty_healthy", + checks={"errors": Eq([])}, + msg="validate should return empty errors for a healthy collection", + ), + DiagnosticTestCase( + "extraIndexEntries_empty_healthy", + checks={"extraIndexEntries": Eq([])}, + msg="validate should return empty extraIndexEntries for a healthy collection", + ), + DiagnosticTestCase( + "missingIndexEntries_empty_healthy", + checks={"missingIndexEntries": Eq([])}, + msg="validate should return empty missingIndexEntries for a healthy collection", + ), + DiagnosticTestCase( + "corruptRecords_empty_healthy", + checks={"corruptRecords": Eq([])}, + msg="validate should return empty corruptRecords for a healthy collection", + ), + DiagnosticTestCase( + "nIndexes_gte_1", + checks={"nIndexes": Gte(1)}, + msg="validate should return nIndexes >= 1 (at least _id index)", + ), + DiagnosticTestCase( + "repairMode_is_string", + checks={"repairMode": IsType("string")}, + msg="validate should return repairMode as a string", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PROPERTY_TESTS)) +def test_validate_response_properties(collection, test): + """Test validate response fields have expected types and values.""" + collection.insert_many([{"_id": i, "x": i} for i in range(5)]) + result = execute_command(collection, {"validate": collection.name}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +# Property [Response Values]: validate returns correct dynamic values matching +# collection state (nrecords, nIndexes, ns). +RESPONSE_VALUE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "nrecords_matches_count", + setup=[ + {"insert": "", "documents": [{"_id": i} for i in range(10)]}, + ], + checks={"nrecords": Eq(10)}, + msg="validate should return nrecords matching document count", + ), + DiagnosticTestCase( + "nIndexes_with_secondary", + setup=[ + {"insert": "", "documents": [{"_id": 1, "x": 1, "y": 1}]}, + { + "createIndexes": "", + "indexes": [ + {"key": {"x": 1}, "name": "x_1"}, + {"key": {"y": 1}, "name": "y_1"}, + ], + }, + ], + checks={"nIndexes": Eq(3)}, + msg="validate should return nIndexes: 3 with two secondary indexes", + ), +] + + +def test_validate_ns_matches_namespace(collection): + """Test validate ns field matches the actual database.collection namespace.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"validate": collection.name}) + expected_ns = f"{collection.database.name}.{collection.name}" + assertSuccessPartial( + result, + {"ns": expected_ns}, + msg="validate should return ns matching the actual namespace", + ) + + +# Property [Detailed Structure]: validate returns correct sub-structure for +# keysPerIndex, indexDetails, and option-specific response shapes. +DETAILED_STRUCTURE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "keysPerIndex_entries", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + { + "createIndexes": "", + "indexes": [{"key": {"x": 1}, "name": "x_1"}], + }, + ], + checks={ + "keysPerIndex._id_": IsType("int"), + "keysPerIndex.x_1": IsType("int"), + }, + msg="keysPerIndex should have integer entries for each index", + ), + DiagnosticTestCase( + "indexDetails_entries", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + { + "createIndexes": "", + "indexes": [{"key": {"x": 1}, "name": "x_1"}], + }, + ], + checks={ + "indexDetails._id_.valid": Eq(True), + "indexDetails._id_.spec": IsType("object"), + "indexDetails.x_1.valid": Eq(True), + "indexDetails.x_1.spec": IsType("object"), + }, + msg="indexDetails entries should have valid: true and spec as object", + ), + DiagnosticTestCase( + "full_true_response", + setup=[{"insert": "", "documents": [{"_id": 1}]}], + command={"full": True}, + checks={ + "ok": Eq(1.0), + "valid": Eq(True), + "ns": IsType("string"), + "nrecords": IsType("int"), + "nIndexes": Gte(1), + "keysPerIndex": IsType("object"), + "indexDetails": IsType("object"), + "repairMode": Eq("None"), + }, + msg="validate with full: true should return all standard response fields", + ), + DiagnosticTestCase( + "metadata_true_response", + setup=[ + {"insert": "", "documents": [{"_id": i, "x": i} for i in range(5)]}, + ], + command={"metadata": True}, + checks={ + "ok": Eq(1.0), + "valid": Eq(True), + "ns": IsType("string"), + "nIndexes": IsType("int"), + "nrecords": NotExists(), + "nInvalidDocuments": NotExists(), + "nNonCompliantDocuments": NotExists(), + }, + msg="validate with metadata: true should return structural fields" + " but omit document-count fields", + ), +] + + +RESPONSE_DETAIL_TESTS = RESPONSE_VALUE_TESTS + DETAILED_STRUCTURE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_DETAIL_TESTS)) +def test_validate_response_details(collection, test): + """Test validate response values, sub-structure, and option-specific shapes.""" + for cmd in test.setup: + execute_command(collection, bind_collection(cmd, collection.name)) + cmd = {"validate": collection.name} + if test.command: + cmd.update(test.command) + result = execute_command(collection, cmd) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py b/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py index 3d08d60c0..65045be33 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/utils/diagnostic_test_case.py @@ -1,4 +1,4 @@ -"""Shared test case for diagnostic command tests.""" +"""Shared test case and utilities for diagnostic command tests.""" from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -21,3 +21,14 @@ class DiagnosticTestCase(BaseTestCase): command: Optional[Dict[str, Any]] = None use_admin: bool = True checks: Dict[str, Any] = field(default_factory=dict) + + +def bind_collection(cmd: Dict[str, Any], name: str) -> Dict[str, Any]: + """Replace the placeholder collection name in a setup command. + + Setup commands use ``""`` as a placeholder for the collection name + (e.g. ``{"insert": "", "documents": [...]}``). This helper overwrites + the first key's value with the real collection name. + """ + command_name = next(iter(cmd)) + return {**cmd, command_name: name} diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 187b41878..8470b1e53 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -36,6 +36,7 @@ INDEX_OPTIONS_CONFLICT_ERROR = 85 INDEX_KEY_SPECS_CONFLICT_ERROR = 86 OPERATION_FAILED_ERROR = 96 +COMMAND_NOT_SUPPORTED_ERROR = 115 DOCUMENT_VALIDATION_FAILURE_ERROR = 121 NOT_A_REPLICA_SET_ERROR = 123 CAPPED_POSITION_LOST_ERROR = 136 diff --git a/documentdb_tests/framework/preconditions.py b/documentdb_tests/framework/preconditions.py index 667853a20..a42e71716 100644 --- a/documentdb_tests/framework/preconditions.py +++ b/documentdb_tests/framework/preconditions.py @@ -58,6 +58,10 @@ "reindex": "reIndex is permitted", "local_rename": "renaming into the unreplicated local database is permitted", "replication": "replication commands are available (applyOps, oplog access)", + "validate_repair": ( + "validate with repair/fixMultikey is permitted and background validation " + "is rejected (standalone-only behavior)" + ), } # The capabilities each (engine, topology) target has. To add an engine or @@ -81,6 +85,7 @@ "unforced_compact", "reindex", "local_rename", + "validate_repair", } ), ("documentdb", "standalone"): frozenset( @@ -95,6 +100,7 @@ "unforced_compact", "reindex", "replication", + "validate_repair", } ), } From 8c826fd1aa6b46b31e0aecb2ee0e9f7c5d717c18 Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:31:03 -0700 Subject: [PATCH 35/51] Add getLog tests (#640) Signed-off-by: PatersonProjects --- .../diagnostic/commands/getLog/__init__.py | 0 .../getLog/test_getLog_argument_validation.py | 41 +++++++ .../commands/getLog/test_getLog_errors.py | 77 +++++++++++++ .../getLog/test_getLog_response_structure.py | 104 ++++++++++++++++++ documentdb_tests/framework/property_checks.py | 19 ++++ 5 files changed, 241 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_argument_validation.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_argument_validation.py new file mode 100644 index 000000000..d5b960f4a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_argument_validation.py @@ -0,0 +1,41 @@ +"""Tests for getLog command argument validation. + +Covers BSON type handling for the ``getLog`` field value. Only a string is +accepted; every non-string type is rejected with TypeMismatch. + +Invalid string values (e.g. unknown components, the deprecated "rs") and +unrecognized command fields are covered in test_getLog_errors.py. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import MISSING_FIELD_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.test_constants import BsonType + +pytestmark = pytest.mark.admin + +BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="getLog_value", + msg="getLog should reject non-string value types", + keyword="getLog", + valid_types=[BsonType.STRING], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_getLog_rejects_non_string_value(collection, bson_type, sample_value, spec): + """Test getLog rejects each non-string BSON type for its value.""" + result = execute_admin_command(collection, {"getLog": sample_value}) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_errors.py new file mode 100644 index 000000000..ebf03c413 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_errors.py @@ -0,0 +1,77 @@ +"""Tests for getLog command error conditions. + +Covers invalid log component names (unknown component, the deprecated "rs" +value, empty string), unrecognized command fields, and the admin-database +requirement. + +BSON type rejection/acceptance for the value is covered in +test_getLog_argument_validation.py. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_FOUND_ERROR, + OPERATION_FAILED_ERROR, + UNAUTHORIZED_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "unknown_component", + command={"getLog": "invalid"}, + error_code=OPERATION_FAILED_ERROR, + msg="Unknown log component name should error", + ), + DiagnosticTestCase( + "deprecated_rs", + command={"getLog": "rs"}, + error_code=OPERATION_FAILED_ERROR, + msg="Deprecated 'rs' value should error", + ), + DiagnosticTestCase( + "empty_string", + command={"getLog": ""}, + error_code=OPERATION_FAILED_ERROR, + msg="Empty string component should error", + ), + DiagnosticTestCase( + "unrecognized_field", + command={"getLog": "global", "unknownField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Unrecognized command field should error", + ), + DiagnosticTestCase( + "non_admin_database", + command={"getLog": "global"}, + use_admin=False, + error_code=UNAUTHORIZED_ERROR, + msg="getLog should only run on the admin database", + ), + DiagnosticTestCase( + "case_sensitive_command_name", + command={"GetLog": "global"}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Command name is case-sensitive; 'GetLog' should not be found", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_getLog_error(collection, test): + """Test getLog returns the expected error code for invalid arguments.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_response_structure.py new file mode 100644 index 000000000..2ddc57cea --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/getLog/test_getLog_response_structure.py @@ -0,0 +1,104 @@ +"""Tests for getLog command response structure. + +Covers response fields for the "global" filter (totalLinesWritten, log array +capped at 1024 entries, string log entries, ok), the "startupWarnings" filter +(totalLinesWritten, log array, ok), and the "*" filter (names array, ok). +Each test asserts a single response property. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ContainsElement, Eq, Gte, IsType, LenLte + +pytestmark = pytest.mark.admin + +MAX_LOG_EVENTS = 1024 + + +RESPONSE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "global_totalLinesWritten_number", + command={"getLog": "global"}, + checks={"totalLinesWritten": Gte(0)}, + msg="global should return a non-negative totalLinesWritten", + ), + DiagnosticTestCase( + "global_log_is_array", + command={"getLog": "global"}, + checks={"log": IsType("array")}, + msg="global should return a log array", + ), + DiagnosticTestCase( + "global_log_capped_at_1024", + command={"getLog": "global"}, + checks={"log": LenLte(MAX_LOG_EVENTS)}, + msg="global log array should contain at most 1024 entries", + ), + DiagnosticTestCase( + "global_log_entry_is_string", + command={"getLog": "global"}, + checks={"log.0": IsType("string")}, + msg="global log entries should be JSON-formatted strings", + ), + DiagnosticTestCase( + "global_ok", + command={"getLog": "global"}, + checks={"ok": Eq(1.0)}, + msg="global should return ok:1", + ), + DiagnosticTestCase( + "startupWarnings_log_is_array", + command={"getLog": "startupWarnings"}, + checks={"log": IsType("array")}, + msg="startupWarnings should return a log array", + ), + DiagnosticTestCase( + "startupWarnings_ok", + command={"getLog": "startupWarnings"}, + checks={"ok": Eq(1.0)}, + msg="startupWarnings should return ok:1", + ), + DiagnosticTestCase( + "startupWarnings_totalLinesWritten_number", + command={"getLog": "startupWarnings"}, + checks={"totalLinesWritten": Gte(0)}, + msg="startupWarnings should return a non-negative totalLinesWritten", + ), + DiagnosticTestCase( + "wildcard_names_is_array", + command={"getLog": "*"}, + checks={"names": IsType("array")}, + msg="'*' should return a names array", + ), + DiagnosticTestCase( + "wildcard_names_contains_global", + command={"getLog": "*"}, + checks={"names": ContainsElement("global")}, + msg="'*' names should include 'global'", + ), + DiagnosticTestCase( + "wildcard_names_contains_startupWarnings", + command={"getLog": "*"}, + checks={"names": ContainsElement("startupWarnings")}, + msg="'*' names should include 'startupWarnings'", + ), + DiagnosticTestCase( + "wildcard_ok", + command={"getLog": "*"}, + checks={"ok": Eq(1.0)}, + msg="'*' should return ok:1", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_TESTS)) +def test_getLog_response_properties(collection, test): + """Verify a getLog response field exists and has the expected type or value.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/framework/property_checks.py b/documentdb_tests/framework/property_checks.py index 2bb4eb7e2..ef80dfb80 100644 --- a/documentdb_tests/framework/property_checks.py +++ b/documentdb_tests/framework/property_checks.py @@ -165,6 +165,25 @@ def __repr__(self) -> str: return f"{type(self).__name__}({self.expected!r})" +class LenLte(Check): + """Assert that the field is a list whose length is at most ``maximum``.""" + + def __init__(self, maximum: int) -> None: + self.maximum = maximum + + def check(self, value: Any, path: str) -> str | None: + if value is _FIELD_ABSENT: + return f"expected '{path}' to have length <= {self.maximum}, but field is missing" + if not isinstance(value, list): + return f"expected '{path}' to be a list, got {type(value).__name__}" + if len(value) > self.maximum: + return f"expected '{path}' length <= {self.maximum}, got {len(value)}" + return None + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.maximum!r})" + + class Contains(Check): """Assert that a list contains a dict where ``key`` equals ``value``.""" From d73329d98453e14f8081136a4cbc5b24ac6f26a0 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 2 Jul 2026 17:41:15 -0700 Subject: [PATCH 36/51] Add fsyncUnlock command tests (#641) Signed-off-by: Daniel Frankcom --- .../commands/fsyncUnlock/conftest.py | 11 + .../commands/fsyncUnlock/test_fsyncUnlock.py | 366 ++++++++++++++++++ .../system/administration/utils/__init__.py | 0 .../system/administration/utils/fsync_lock.py | 40 ++ .../framework/test_structure_validator.py | 2 +- 5 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/conftest.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/test_fsyncUnlock.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/utils/fsync_lock.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/conftest.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/conftest.py new file mode 100644 index 000000000..2cc68cf91 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/conftest.py @@ -0,0 +1,11 @@ +"""Shared fixtures for fsyncUnlock tests. + +Re-exports the autouse fsync-lock baseline fixture so it applies to every test +in this directory without each test file importing it. +""" + +from documentdb_tests.compatibility.tests.system.administration.utils.fsync_lock import ( + unlocked_baseline, +) + +__all__ = ["unlocked_baseline"] diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/test_fsyncUnlock.py b/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/test_fsyncUnlock.py new file mode 100644 index 000000000..14b18c0e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/fsyncUnlock/test_fsyncUnlock.py @@ -0,0 +1,366 @@ +"""Tests for fsyncUnlock command behavior.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from bson import Int64 +from pymongo import MongoClient + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertResult, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import ( + API_STRICT_ERROR, + ILLEGAL_OPERATION_ERROR, + INVALID_OPTIONS_ERROR, + OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import ( + execute_admin_command, + execute_command, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType, NotExists +from documentdb_tests.framework.test_constants import ( + BSON_TYPE_SAMPLES, + DOUBLE_ZERO, + INT64_ZERO, +) + +# fsyncUnlock decrements a server-global lock count, so these tests must never +# run in parallel with anything that takes or releases the fsync lock. +pytestmark = pytest.mark.no_parallel + + +# Sentinel marking an FsyncUnlockCase field that the caller must set. Every +# field inherited from CommandTestCase has a default, so the dataclass cannot +# make these positionally required; instead they default to this sentinel and +# __post_init__ rejects it, forcing each case to state its lock-state +# preconditions explicitly rather than inheriting a hidden default. +_REQUIRED = object() + + +@dataclass(frozen=True) +class FsyncUnlockCase(CommandTestCase): + """A command-test case carrying fsyncUnlock's lock-state preconditions. + + fsyncUnlock's success path depends on the current lock count, which is not + derivable from the command itself, so each case declares how many fsync + locks to take and how many unlocks to issue before the command under test + runs. Both are required: no default lock state is assumed. + """ + + locks_taken: int = _REQUIRED # type: ignore[assignment] + unlocks_before: int = _REQUIRED # type: ignore[assignment] + + def __post_init__(self) -> None: + super().__post_init__() + if self.locks_taken is _REQUIRED or self.unlocks_before is _REQUIRED: + raise ValueError( + f"FsyncUnlockCase '{self.id}' must set locks_taken and unlocks_before explicitly" + ) + + +# Property [Response Shape and Return Types]: lockCount is a BSON Int64 holding +# the remaining lock count (not a hardcoded 0) and seeAlso is absent (it belongs +# only to the fsync lock response). +FSYNCUNLOCK_RESPONSE_SHAPE_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "response_shape", + # Lock twice so the unlock leaves a nonzero remaining count; this proves + # lockCount reflects the locks remaining rather than a hardcoded 0. + locks_taken=2, + unlocks_before=0, + command={"fsyncUnlock": 1}, + expected={ + "info": Eq("fsyncUnlock completed"), + "lockCount": [IsType("long"), Eq(Int64(1))], + "ok": [IsType("double"), Eq(1.0)], + "seeAlso": NotExists(), + }, + msg="fsyncUnlock should return info, an Int64 lockCount of the remaining " + "locks, a double ok of 1.0, and no seeAlso field", + ), +] + +# Property [Command-Key Value Handling]: the command-key value is ignored across +# every BSON type, so each still succeeds and decrements normally. +FSYNCUNLOCK_COMMAND_KEY_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + f"command_key_{bson_type.value}", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": val}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should ignore its command-key value and decrement the " + "lock count by exactly 1 on the locked path", + ) + for bson_type, val in BSON_TYPE_SAMPLES.items() +] + +# Property [Comment Field Handling]: a comment of any BSON type is accepted +# untyped, succeeds, and is never echoed in the reply. +FSYNCUNLOCK_COMMENT_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + f"comment_{bson_type.value}", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "comment": val}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0), "comment": NotExists()}, + msg="fsyncUnlock should accept any comment value, decrement the lock " + "count by exactly 1, and never echo the comment in the reply", + ) + for bson_type, val in BSON_TYPE_SAMPLES.items() +] + +# Property [Generic Command Options Accepted]: generic command-envelope options +# fall through to the normal path, leaving the success+decrement outcome +# unchanged. +FSYNCUNLOCK_GENERIC_OPTION_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "generic_read_concern_local", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "readConcern": {"level": "local"}}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_read_preference_primary", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "$readPreference": {"mode": "primary"}}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_read_preference_secondary_preferred", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "$readPreference": {"mode": "secondaryPreferred"}}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_max_time_ms_zero", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "maxTimeMS": 0}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_max_time_ms_zero_float", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "maxTimeMS": DOUBLE_ZERO}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), + FsyncUnlockCase( + "generic_unknown_extra_field", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "someUnknownField": 1}, + expected={"lockCount": Eq(INT64_ZERO), "ok": Eq(1.0)}, + msg="fsyncUnlock should accept the generic command option and decrement " + "the lock count by exactly 1 on the locked path", + ), +] + +# Property [Error: Instance Not Locked]: unlocking at lock count 0 errors with +# IllegalOperation rather than no-opping, whether never raised or driven back to +# 0 by over-unlocking. +FSYNCUNLOCK_NOT_LOCKED_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "not_locked_never_raised", + locks_taken=0, + unlocks_before=0, + command={"fsyncUnlock": 1}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="fsyncUnlock should error as not-locked when the lock count is " + "already 0 instead of silently no-opping", + ), + FsyncUnlockCase( + "over_unlock_below_zero", + locks_taken=1, + unlocks_before=1, + command={"fsyncUnlock": 1}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="fsyncUnlock should error as not-locked when the lock count is " + "already 0 instead of silently no-opping", + ), +] + +# Property [Error: writeConcern Not Supported]: a writeConcern envelope errors +# with InvalidOptions even with a lock held; the command does not support +# writeConcern. +FSYNCUNLOCK_WRITE_CONCERN_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "write_concern_w1", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "writeConcern": {"w": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="fsyncUnlock should reject a writeConcern envelope while a lock is held", + ), +] + +# Property [Error: readConcern Non-Local Levels]: a non-local readConcern level +# errors with InvalidOptions even with a lock held; only the local level is +# supported. +FSYNCUNLOCK_READ_CONCERN_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + f"read_concern_{level}", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "readConcern": {"level": level}}, + error_code=INVALID_OPTIONS_ERROR, + msg="fsyncUnlock should reject a non-local readConcern level while a lock is held", + ) + for level in ("majority", "linearizable", "available", "snapshot") +] + +# Property [Error: Stable API Rejection]: under apiVersion 1 + apiStrict the +# command errors with APIStrictError even with a lock held; it is not in API +# Version 1. +FSYNCUNLOCK_API_STRICT_TESTS: list[FsyncUnlockCase] = [ + FsyncUnlockCase( + "api_strict", + locks_taken=1, + unlocks_before=0, + command={"fsyncUnlock": 1, "apiVersion": "1", "apiStrict": True}, + error_code=API_STRICT_ERROR, + msg="fsyncUnlock should be rejected under apiStrict true while a lock is held", + ), +] + +FSYNCUNLOCK_TESTS = ( + FSYNCUNLOCK_RESPONSE_SHAPE_TESTS + + FSYNCUNLOCK_COMMAND_KEY_TESTS + + FSYNCUNLOCK_COMMENT_TESTS + + FSYNCUNLOCK_GENERIC_OPTION_TESTS + + FSYNCUNLOCK_NOT_LOCKED_TESTS + + FSYNCUNLOCK_WRITE_CONCERN_TESTS + + FSYNCUNLOCK_READ_CONCERN_TESTS + + FSYNCUNLOCK_API_STRICT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(FSYNCUNLOCK_TESTS)) +def test_fsyncUnlock_cases(collection, test): + """Test fsyncUnlock cases against its response contract on the admin database.""" + for _ in range(test.locks_taken): + execute_admin_command(collection, {"fsync": 1, "lock": True}) + for _ in range(test.unlocks_before): + execute_admin_command(collection, {"fsyncUnlock": 1}) + result = execute_admin_command(collection, test.command) + assertResult( + result, + expected=test.expected, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Error: Non-Admin Database]: a non-admin database errors with +# Unauthorized even with a lock held - the admin-scope dispatch check, not an +# auth-privilege failure. +def test_fsyncUnlock_rejects_non_admin_database(collection): + """Test fsyncUnlock rejects a non-admin database while a lock is held.""" + execute_admin_command(collection, {"fsync": 1, "lock": True}) + result = execute_command(collection, {"fsyncUnlock": 1}) + assertFailureCode( + result, + UNAUTHORIZED_ERROR, + msg="fsyncUnlock should reject a non-admin database while a lock is held", + ) + + +# Property [Cross-Connection Lock Sharing]: the lock count is server-global, so +# an unlock on one connection releases a lock taken on another. +def test_fsyncUnlock_releases_lock_taken_on_another_connection(collection, connection_string): + """Test fsyncUnlock releases a lock that was taken on a different connection.""" + other_client: MongoClient = MongoClient(connection_string) + try: + # Take the lock on a separate connection and pool. + other_client.admin.command({"fsync": 1, "lock": True}) + # Release it from the primary connection; if the count were + # per-connection this unlock would instead error as not-locked. + result = execute_admin_command(collection, {"fsyncUnlock": 1}) + assertSuccessPartial( + result, + {"lockCount": INT64_ZERO, "ok": 1.0}, + msg="fsyncUnlock should release a lock taken on another connection " + "and report the shared count at 0", + ) + finally: + other_client.close() + + +# Property [Explicit Session Accepted]: an explicit client session is accepted +# and behaves identically to a sessionless invocation. +def test_fsyncUnlock_accepts_explicit_session(collection): + """Test fsyncUnlock runs under an explicit client session and behaves identically.""" + execute_admin_command(collection, {"fsync": 1, "lock": True}) + session = collection.database.client.start_session() + try: + result = execute_admin_command(collection, {"fsyncUnlock": 1}, session=session) + assertSuccessPartial( + result, + {"lockCount": INT64_ZERO, "ok": 1.0}, + msg="fsyncUnlock should run under an explicit session and decrement the " + "lock count by exactly 1", + ) + finally: + session.end_session() + + +# Property [Error: Non-Admin Database Consumes No Lock]: a non-admin rejection +# does not consume a held lock, so a following admin fsyncUnlock still decrements +# the count to 0. +def test_fsyncUnlock_non_admin_consumes_no_lock(collection): + """Test fsyncUnlock non-admin rejection does not consume a held lock.""" + execute_admin_command(collection, {"fsync": 1, "lock": True}) + execute_command(collection, {"fsyncUnlock": 1}) + result = execute_admin_command(collection, {"fsyncUnlock": 1}) + assertSuccessPartial( + result, + {"lockCount": INT64_ZERO, "ok": 1.0}, + msg="fsyncUnlock non-admin rejection should not consume a held lock, so a " + "following real unlock still decrements the count to 0", + ) + + +# Property [Error: Multi-Document Transaction]: inside a multi-document +# transaction fsyncUnlock errors with OperationNotSupportedInTransaction. +@pytest.mark.requires(transactions=True) +def test_fsyncUnlock_rejects_multi_document_transaction(collection): + """Test fsyncUnlock errors when issued inside a multi-document transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + try: + result = execute_admin_command(collection, {"fsyncUnlock": 1}, session=session) + finally: + session.abort_transaction() + assertFailureCode( + result, + OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="fsyncUnlock should error as not supported when issued inside a " + "multi-document transaction", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/utils/fsync_lock.py b/documentdb_tests/compatibility/tests/system/administration/utils/fsync_lock.py new file mode 100644 index 000000000..219c3ed22 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/utils/fsync_lock.py @@ -0,0 +1,40 @@ +"""Shared fsync-lock state management for fsync and fsyncUnlock tests. + +The fsync lock is a server-global count that neither command's collection +fixture manages, so both test directories need a way to return the server to a +known unlocked baseline. The drain helper and the autouse baseline fixture here +are the single source of truth; each command directory's conftest re-exports the +fixture so it applies to that directory's tests. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.error_codes import ILLEGAL_OPERATION_ERROR +from documentdb_tests.framework.executor import execute_admin_command + + +def drain_fsync_lock(collection) -> None: + """Release outstanding fsync locks until the server-global count reaches 0. + + Loops fsyncUnlock until the reported count is 0, treating the not-locked + error as "already 0" and re-raising any other error so an unexpected failure + cannot silently leave the server locked. + """ + while True: + result = execute_admin_command(collection, {"fsyncUnlock": 1}) + if isinstance(result, Exception): + if getattr(result, "code", None) == ILLEGAL_OPERATION_ERROR: + return + raise result + if result.get("lockCount", 0) == 0: + return + + +@pytest.fixture(autouse=True) +def unlocked_baseline(collection): + """Drain the server-global fsync lock before and after every test.""" + drain_fsync_lock(collection) + yield + drain_fsync_lock(collection) diff --git a/documentdb_tests/framework/test_structure_validator.py b/documentdb_tests/framework/test_structure_validator.py index 57f811f08..dd40a3f75 100644 --- a/documentdb_tests/framework/test_structure_validator.py +++ b/documentdb_tests/framework/test_structure_validator.py @@ -15,7 +15,7 @@ def validate_python_files_in_tests(tests_dir: Path) -> list[str]: allowed_folders = {"utils", "fixtures", "__pycache__"} for py_file in tests_dir.rglob("*.py"): - if py_file.name == "__init__.py": + if py_file.name in ("__init__.py", "conftest.py"): continue if any(folder in py_file.parts for folder in allowed_folders): continue From c4dbb6d59a804c6a35aa05720b3e1bebec9d652e Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Thu, 2 Jul 2026 17:48:45 -0700 Subject: [PATCH 37/51] Add admin command tests for logRotate (#642) Signed-off-by: Victor [C] Tsang --- .../commands/logRotate/__init__.py | 0 .../test_logRotate_bson_type_validation.py | 99 +++++++++++++++++++ .../logRotate/test_logRotate_errors.py | 86 ++++++++++++++++ .../test_logRotate_value_acceptance.py | 37 +++++++ .../logRotate/test_smoke_logRotate.py | 17 ++-- .../administration/utils/admin_test_case.py | 22 +++++ documentdb_tests/framework/error_codes.py | 1 + documentdb_tests/framework/executor.py | 29 ++++++ .../framework/test_format_validator.py | 1 + 9 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/logRotate/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_value_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_bson_type_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_bson_type_validation.py new file mode 100644 index 000000000..a41ea2222 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_bson_type_validation.py @@ -0,0 +1,99 @@ +"""Tests for logRotate BSON type validation. + +The value field accepts numeric/bool types and the string "server"; other +types are rejected with TypeMismatch. The comment field accepts all BSON +types and is consumed by the server rather than echoed back in the response. +""" + +import pytest + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertProperties, + assertSuccessPartial, +) +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import FILE_RENAME_FAILED_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import ( + execute_admin_command, + execute_admin_with_retry_command, +) +from documentdb_tests.framework.property_checks import Eq, NotExists + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +LOG_ROTATE_VALUE_PARAMS = [ + BsonTypeTestCase( + id="logRotate_value", + msg="logRotate value should accept numeric, bool, and the 'server' string", + keyword="logRotate", + valid_types=[ + BsonType.INT, + BsonType.DOUBLE, + BsonType.LONG, + BsonType.BOOL, + BsonType.DECIMAL, + BsonType.STRING, + ], + valid_inputs={BsonType.STRING: "server"}, + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +COMMENT_PARAMS = [ + BsonTypeTestCase( + id="comment", + msg="logRotate should accept all BSON types for the comment field", + keyword="comment", + valid_types=list(BsonType), + ), +] + + +LOG_ROTATE_VALUE_ACCEPTANCE = generate_bson_acceptance_test_cases(LOG_ROTATE_VALUE_PARAMS) +LOG_ROTATE_VALUE_REJECTIONS = generate_bson_rejection_test_cases(LOG_ROTATE_VALUE_PARAMS) +COMMENT_ACCEPTANCE = generate_bson_acceptance_test_cases(COMMENT_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", LOG_ROTATE_VALUE_ACCEPTANCE) +def test_logRotate_value_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test logRotate accepts numeric, bool, and the 'server' string value.""" + result = execute_admin_with_retry_command( + collection, {"logRotate": sample_value}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg=f"{spec.msg} (bson_type={bson_type.value})", + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", COMMENT_ACCEPTANCE) +def test_logRotate_comment_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test comment field accepts all BSON types and is consumed, not echoed back.""" + result = execute_admin_with_retry_command( + collection, {"logRotate": 1, "comment": sample_value}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertProperties( + result, + {"ok": Eq(1.0), "comment": NotExists()}, + msg=f"comment should be accepted and not echoed back ({bson_type.value})", + raw_res=True, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", LOG_ROTATE_VALUE_REJECTIONS) +def test_logRotate_value_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test logRotate value rejects non-numeric, non-string BSON types.""" + result = execute_admin_command(collection, {"logRotate": sample_value}) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"logRotate should reject {bson_type.value} for the command value", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_errors.py new file mode 100644 index 000000000..8e7a2aa33 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_errors.py @@ -0,0 +1,86 @@ +"""Tests for logRotate command error cases.""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.admin_test_case import ( + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + NO_SUCH_KEY_ERROR, + UNAUTHORIZED_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +INVALID_STRING_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "invalid_string", + command={"logRotate": "invalid"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Should reject invalid string value", + ), + AdminTestCase( + "empty_string", + command={"logRotate": ""}, + error_code=NO_SUCH_KEY_ERROR, + msg="Should reject empty string", + ), + AdminTestCase( + "case_sensitive_SERVER", + command={"logRotate": "SERVER"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Should reject uppercase SERVER (case-sensitive)", + ), + AdminTestCase( + "case_sensitive_Audit", + command={"logRotate": "Audit"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Should reject mixed-case Audit (case-sensitive)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_STRING_TESTS)) +def test_logRotate_invalid_arguments(collection, test): + """Test that logRotate rejects invalid string values.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_logRotate_unrecognized_field(collection): + """Test that logRotate rejects unrecognized top-level fields.""" + result = execute_admin_command(collection, {"logRotate": 1, "unknownField": 1}) + assertFailureCode( + result, UNRECOGNIZED_COMMAND_FIELD_ERROR, msg="Should reject unrecognized fields" + ) + + +def test_logRotate_unrecognized_field_with_comment(collection): + """Test that an unrecognized field still fails when a valid comment is present.""" + result = execute_admin_command( + collection, {"logRotate": 1, "comment": "hello", "unknownField": 1} + ) + assertFailureCode( + result, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject unrecognized fields even when a valid comment is present", + ) + + +def test_logRotate_non_admin_database(collection): + """Test that logRotate fails when run on a non-admin database.""" + result = execute_command(collection, {"logRotate": 1}) + assertFailureCode(result, UNAUTHORIZED_ERROR, msg="Should fail on non-admin database") + + +def test_logRotate_audit_target_rejected_when_disabled(collection): + """Test that the 'audit' log target is rejected when auditing is disabled.""" + result = execute_admin_command(collection, {"logRotate": "audit"}) + assertFailureCode( + result, NO_SUCH_KEY_ERROR, msg="Should reject audit target when auditing is disabled" + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_value_acceptance.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_value_acceptance.py new file mode 100644 index 000000000..40b414a65 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_logRotate_value_acceptance.py @@ -0,0 +1,37 @@ +"""Tests for logRotate acceptance of specific scalar values. + +Covers values the shared BSON-type harness does not sample: boolean `false`, `0`, +and negative integers/longs (it only feeds `True` and `INT32_MAX`/`INT64_MAX`). +Each rotation goes through `execute_admin_with_retry_command`, which retries past the +transient FileRenameFailed so the test can assert a clean success. +""" + +import pytest +from bson.int64 import Int64 + +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.error_codes import FILE_RENAME_FAILED_ERROR +from documentdb_tests.framework.executor import execute_admin_with_retry_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +def test_logRotate_value_bool_false_accepted(collection): + """Test logRotate accepts boolean false (the BSON sample only covers true).""" + result = execute_admin_with_retry_command( + collection, {"logRotate": False}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertSuccessPartial(result, {"ok": 1.0}, msg="logRotate value should accept boolean false") + + +@pytest.mark.parametrize( + "value", + [0, -1, Int64(-5)], + ids=["zero", "negative_int", "negative_long"], +) +def test_logRotate_value_zero_and_negative_accepted(collection, value): + """Test logRotate accepts 0 and negative integers (BSON samples only cover max values).""" + result = execute_admin_with_retry_command( + collection, {"logRotate": value}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertSuccessPartial(result, {"ok": 1.0}, msg=f"logRotate value should accept {value!r}") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_smoke_logRotate.py b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_smoke_logRotate.py index f3916594a..30f250b88 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_smoke_logRotate.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/logRotate/test_smoke_logRotate.py @@ -1,20 +1,17 @@ -""" -Smoke test for logRotate command. - -Tests basic logRotate functionality. -""" +"""Smoke test for logRotate command.""" import pytest from documentdb_tests.framework.assertions import assertSuccessPartial -from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.error_codes import FILE_RENAME_FAILED_ERROR +from documentdb_tests.framework.executor import execute_admin_with_retry_command pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel] def test_smoke_logRotate(collection): """Test basic logRotate behavior.""" - result = execute_admin_command(collection, {"logRotate": 1}) - - expected = {"ok": 1.0} - assertSuccessPartial(result, expected, msg="Should support logRotate command") + result = execute_admin_with_retry_command( + collection, {"logRotate": 1}, retry_code=FILE_RENAME_FAILED_ERROR + ) + assertSuccessPartial(result, {"ok": 1.0}, msg="Should support logRotate command") diff --git a/documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py b/documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py new file mode 100644 index 000000000..1780e1416 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/utils/admin_test_case.py @@ -0,0 +1,22 @@ +"""Shared test case for administration command tests.""" + +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class AdminTestCase(BaseTestCase): + """Test case for administration command tests. + + Inherits ``id``, ``expected``, ``error_code``, ``msg``, and ``marks`` from + ``BaseTestCase``. + + Attributes: + command: The command document to execute. + use_admin: If True, execute against the admin database. + """ + + command: Optional[Dict[str, Any]] = None + use_admin: bool = True diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 8470b1e53..78a86b0c1 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -18,6 +18,7 @@ NAMESPACE_NOT_FOUND_ERROR = 26 INDEX_NOT_FOUND_ERROR = 27 PATH_NOT_VIABLE_ERROR = 28 +FILE_RENAME_FAILED_ERROR = 37 CONFLICTING_UPDATE_OPERATORS_ERROR = 40 CURSOR_NOT_FOUND_ERROR = 43 NO_MATCHING_DOCUMENT_ERROR = 47 diff --git a/documentdb_tests/framework/executor.py b/documentdb_tests/framework/executor.py index 4ce374799..49febfa3b 100644 --- a/documentdb_tests/framework/executor.py +++ b/documentdb_tests/framework/executor.py @@ -2,6 +2,7 @@ Unified execution and assertion utilities for tests. """ +import time from datetime import timezone from typing import Any, Dict @@ -50,3 +51,31 @@ def execute_admin_command(collection, command: Dict, session=None) -> Any: return result except Exception as e: return e + + +def execute_admin_with_retry_command( + collection, command: Dict, *, retry_code: int, timeout: float = 30.0, interval: float = 0.2 +) -> Any: + """ + Run an admin command, retrying while it fails with ``retry_code``. + + Any other result (success or a different error) is returned immediately. On + timeout, the last result is returned as-is. + + Args: + collection: DocumentDB collection + command: Command to execute via runCommand on the admin database + retry_code: Error code to treat as transient and retry past + timeout: Maximum seconds to keep retrying before returning the last result + interval: Seconds to wait between attempts + + Returns: + Result if successful, Exception if failed + """ + deadline = time.monotonic() + timeout + while True: + result = execute_admin_command(collection, command) + should_retry = isinstance(result, Exception) and getattr(result, "code", None) == retry_code + if not should_retry or time.monotonic() >= deadline: + return result + time.sleep(interval) diff --git a/documentdb_tests/framework/test_format_validator.py b/documentdb_tests/framework/test_format_validator.py index 295b1351d..4d224c66b 100644 --- a/documentdb_tests/framework/test_format_validator.py +++ b/documentdb_tests/framework/test_format_validator.py @@ -32,6 +32,7 @@ def validate_test_format(file_path: str) -> list[str]: "execute_project_with_insert", "execute_expression", "execute_expression_with_insert", + "execute_admin_with_retry_command", ] ) From 18c75e4ef9e42796d776fa6646d7552d2468ae99 Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:50:40 -0700 Subject: [PATCH 38/51] Add hostInfo tests (#644) Signed-off-by: PatersonProjects --- .../diagnostic/commands/hostInfo/__init__.py | 0 .../test_hostInfo_argument_handling.py | 37 ++++ .../hostInfo/test_hostInfo_consistency.py | 51 ++++++ .../test_hostInfo_error_conditions.py | 47 +++++ .../test_hostInfo_response_structure.py | 171 ++++++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_consistency.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_argument_handling.py new file mode 100644 index 000000000..d75e3df49 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_argument_handling.py @@ -0,0 +1,37 @@ +"""Tests for hostInfo command argument handling. + +hostInfo takes no arguments: the command value is ignored and any BSON type +is accepted, always returning ok:1. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import BsonType + +pytestmark = pytest.mark.admin + + +BSON_TYPE_PARAMS = [ + BsonTypeTestCase( + id="hostInfo_command_value", + msg="hostInfo should accept any BSON type as command value", + keyword="hostInfo", + valid_types=list(BsonType), + ), +] + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(BSON_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_hostInfo_argument_types(collection, bson_type, sample_value, spec): + """Test that hostInfo accepts any BSON type as its command value.""" + result = execute_admin_command(collection, {"hostInfo": sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=spec.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_consistency.py new file mode 100644 index 000000000..41ae6868e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_consistency.py @@ -0,0 +1,51 @@ +"""Tests for hostInfo core behavior and consistency. + +Validates that hostInfo ignores its argument value and succeeds across +different database contexts. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +ACCESS_TESTS = [ + # The MongoDB manual states hostInfo must be run against the admin database, + # but in practice it succeeds on any database. + DiagnosticTestCase( + id="succeeds_on_non_admin_db", + use_admin=False, + checks={"ok": Eq(1.0)}, + msg="hostInfo should succeed on a non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ACCESS_TESTS)) +def test_hostInfo_access(collection, test): + """Verify hostInfo access behaviour across different database contexts.""" + if test.use_admin: + result = execute_admin_command(collection, {"hostInfo": 1}) + else: + result = execute_command(collection, {"hostInfo": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_hostInfo_argument_value_ignored(collection): + """Verify the command value does not affect the os/extra output.""" + numeric = execute_admin_command(collection, {"hostInfo": 1}) + other = execute_admin_command(collection, {"hostInfo": "ignored"}) + assertProperties( + other, + {"os": Eq(numeric.get("os")), "extra": Eq(numeric.get("extra"))}, + raw_res=True, + msg="hostInfo output should not depend on the command value", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_error_conditions.py new file mode 100644 index 000000000..c1d4e2857 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_error_conditions.py @@ -0,0 +1,47 @@ +"""Tests for hostInfo command error conditions. + +Validates that invalid usages of hostInfo produce the appropriate error codes. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_FOUND_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="unrecognized_field", + command={"hostInfo": 1, "unknownField": 1}, + use_admin=True, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject unrecognized fields", + ), + DiagnosticTestCase( + id="case_sensitive", + command={"HostInfo": 1}, + use_admin=True, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Case-mismatched command name should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_hostInfo_error_conditions(collection, test): + """Verifies hostInfo rejects invalid usages with appropriate error codes.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_response_structure.py new file mode 100644 index 000000000..a6abd273c --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/hostInfo/test_hostInfo_response_structure.py @@ -0,0 +1,171 @@ +"""Tests for hostInfo command response structure. + +Validates presence, types, and value constraints of the system, os, and extra +sub-documents returned by hostInfo. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import ( + Gt, + Gte, + IsType, + NonEmptyStr, +) + +pytestmark = pytest.mark.admin + + +PROPERTY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="system_is_object", + checks={"system": IsType("object")}, + msg="'system' should be an embedded document", + ), + DiagnosticTestCase( + id="os_is_object", + checks={"os": IsType("object")}, + msg="'os' should be an embedded document", + ), + DiagnosticTestCase( + id="extra_is_object", + checks={"extra": IsType("object")}, + msg="'extra' should be an embedded document", + ), + DiagnosticTestCase( + id="system_currentTime_is_date", + checks={"system.currentTime": IsType("date")}, + msg="'system.currentTime' should be a date", + ), + DiagnosticTestCase( + id="system_hostname_nonempty", + checks={"system.hostname": NonEmptyStr()}, + msg="'system.hostname' should be a non-empty string", + ), + DiagnosticTestCase( + id="system_cpuAddrSize_positive", + checks={"system.cpuAddrSize": Gt(0)}, + msg="'system.cpuAddrSize' should be greater than 0", + ), + DiagnosticTestCase( + id="system_memLimitMB_positive", + checks={"system.memLimitMB": Gt(0)}, + msg="'system.memLimitMB' should be greater than 0", + ), + DiagnosticTestCase( + id="system_cpuArch_nonempty", + checks={"system.cpuArch": NonEmptyStr()}, + msg="'system.cpuArch' should be a non-empty string", + ), + DiagnosticTestCase( + id="system_numaEnabled_is_bool", + checks={"system.numaEnabled": IsType("bool")}, + msg="'system.numaEnabled' should be a bool", + ), + # The following three fields (numPhysicalCores, numCpuSockets, numNumaNodes) are + # observed in DocumentDB responses but are NOT listed in the MongoDB manual's + # hostInfo reference. The manual only documents numCores and + # numCoresAvailableToProcess under the system sub-document. + DiagnosticTestCase( + id="system_numPhysicalCores_positive", + checks={"system.numPhysicalCores": Gt(0)}, + msg="'system.numPhysicalCores' should be greater than 0", + ), + DiagnosticTestCase( + id="system_numCpuSockets_positive", + checks={"system.numCpuSockets": Gt(0)}, + msg="'system.numCpuSockets' should be greater than 0", + ), + DiagnosticTestCase( + id="system_numNumaNodes_positive", + checks={"system.numNumaNodes": Gt(0)}, + msg="'system.numNumaNodes' should be greater than 0", + ), + DiagnosticTestCase( + id="os_type_nonempty", + checks={"os.type": NonEmptyStr()}, + msg="'os.type' should be a non-empty string", + ), + DiagnosticTestCase( + id="os_name_is_string", + checks={"os.name": IsType("string")}, + msg="'os.name' should be a string", + ), + DiagnosticTestCase( + id="os_version_is_string", + checks={"os.version": IsType("string")}, + msg="'os.version' should be a string", + ), + DiagnosticTestCase( + id="extra_versionString_is_string", + checks={"extra.versionString": IsType("string")}, + msg="'extra.versionString' should be a string", + ), + DiagnosticTestCase( + id="extra_pageSize_positive", + checks={"extra.pageSize": Gt(0)}, + msg="'extra.pageSize' should be greater than 0", + ), + DiagnosticTestCase( + id="system_numCores_positive", + checks={"system.numCores": Gte(1)}, + msg="'system.numCores' should be at least 1", + ), + DiagnosticTestCase( + id="system_numCoresAvailableToProcess_gte_neg1", + checks={"system.numCoresAvailableToProcess": Gte(-1)}, + msg="'system.numCoresAvailableToProcess' should be >= -1", + ), + DiagnosticTestCase( + id="system_memSizeMB_positive", + checks={"system.memSizeMB": Gt(0)}, + msg="'system.memSizeMB' should be greater than 0", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PROPERTY_TESTS)) +def test_hostInfo_response_properties(collection, test): + """Verifies hostInfo response fields have expected types and values.""" + result = execute_admin_command(collection, {"hostInfo": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_hostInfo_memSizeMB_gte_memLimitMB(collection): + """Verify system.memLimitMB does not exceed system.memSizeMB.""" + result = execute_admin_command(collection, {"hostInfo": 1}) + mem_limit = result.get("system", {}).get("memLimitMB") + assertProperties( + result, + {"system.memSizeMB": Gte(mem_limit)}, + raw_res=True, + msg="'system.memSizeMB' should be >= 'system.memLimitMB'", + ) + + +def test_hostInfo_extra_platform_specific_fields(collection): + """Verify extra contains the OS-specific fields documented for the host platform.""" + result = execute_admin_command(collection, {"hostInfo": 1}) + os_type = result.get("os", {}).get("type") + if os_type == "Linux": + checks = { + "extra.libcVersion": IsType("string"), + "extra.kernelVersion": IsType("string"), + "extra.numPages": Gt(0), + "extra.maxOpenFiles": Gt(0), + } + elif os_type == "Darwin": + checks = { + "extra.cpuString": IsType("string"), + } + else: + pytest.skip(f"Unrecognized os.type {os_type!r}; platform-specific fields not asserted") + assertProperties( + result, checks, raw_res=True, msg=f"extra should match documented {os_type} fields" + ) From c5dd02eccad622074b75997b7404731f4d558a1b Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:17:51 -0700 Subject: [PATCH 39/51] Add `killSessions` command tests (#593) Signed-off-by: Alina (Xi) Li --- .../commands/killSessions/__init__.py | 0 .../test_killSessions_acceptance.py | 236 ++++++++++++++++++ .../killSessions/test_killSessions_core.py | 213 ++++++++++++++++ .../test_killSessions_field_type_error.py | 155 ++++++++++++ .../test_killSessions_maxtimems_error.py | 143 +++++++++++ .../test_killSessions_options_error.py | 103 ++++++++ .../test_killSessions_readconcern_error.py | 205 +++++++++++++++ .../test_killSessions_sessions_type_error.py | 199 +++++++++++++++ 8 files changed, 1254 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_core.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_field_type_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_maxtimems_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_options_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_readconcern_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_sessions_type_error.py diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/__init__.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_acceptance.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_acceptance.py new file mode 100644 index 000000000..51c88d5f3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_acceptance.py @@ -0,0 +1,236 @@ +"""Tests for killSessions parameter acceptance.""" + +from __future__ import annotations + +import uuid + +import pytest +from bson import Binary, Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.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_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT32_MAX, + INT64_ZERO, +) + +pytestmark = pytest.mark.no_parallel + +# Property [maxTimeMS Acceptance]: maxTimeMS accepts values at both +# boundaries of the valid range across all numeric types. +KILLSESSIONS_MAXTIMEMS_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"maxtimems_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "maxTimeMS": v, + }, + expected={"ok": 1.0}, + msg=f"killSessions should accept {tid} maxTimeMS", + ) + for tid, val in [ + # Lower boundary (0) in all representations. + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + # Upper boundary (INT32_MAX) in all representations. + ("int32_max", INT32_MAX), + ("int64_max", Int64(INT32_MAX)), + ("double_max", float(INT32_MAX)), + ("decimal128_max", Decimal128(str(INT32_MAX))), + # Null treated as omitted. + ("null", None), + ] +] + +# Property [writeConcern Null Acceptance]: null writeConcern is treated +# as omitted and accepted. +KILLSESSIONS_WRITECONCERN_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_null", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "writeConcern": None, + }, + expected={"ok": 1.0}, + msg="killSessions should accept null writeConcern", + ), +] + +# Property [Unrecognized Fields]: unknown fields in the command document +# are silently ignored. +KILLSESSIONS_UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unrecognized_single", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "unknownField": 1, + }, + expected={"ok": 1.0}, + msg="killSessions should ignore a single unknown field", + ), + CommandTestCase( + "unrecognized_multiple", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "foo": 1, + "bar": 2, + }, + expected={"ok": 1.0}, + msg="killSessions should ignore multiple unknown fields", + ), + CommandTestCase( + "unrecognized_dollar_prefix", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "$unknown": 1, + }, + expected={"ok": 1.0}, + msg="killSessions should ignore dollar-prefixed unknown field", + ), + CommandTestCase( + "unrecognized_other_command", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "query": {"x": 1}, + }, + expected={"ok": 1.0}, + msg="killSessions should ignore field from another command", + ), +] + +# Property [readConcern Acceptance]: readConcern with level "local", null, +# empty document, or null level is accepted without error. +KILLSESSIONS_READCONCERN_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_local", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "local"}, + }, + expected={"ok": 1.0}, + msg="killSessions should accept readConcern with level local", + ), + CommandTestCase( + "readconcern_null", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": None, + }, + expected={"ok": 1.0}, + msg="killSessions should accept null readConcern", + ), + CommandTestCase( + "readconcern_empty_doc", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {}, + }, + expected={"ok": 1.0}, + msg="killSessions should accept empty readConcern document", + ), + CommandTestCase( + "readconcern_level_null", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": None}, + }, + expected={"ok": 1.0}, + msg="killSessions should accept readConcern with null level", + ), +] + +# Property [Null Array Element Acceptance]: null elements in the array +# are silently accepted without error. +KILLSESSIONS_NULL_ELEMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "element_null_accepted", + command=lambda ctx: {"killSessions": [None]}, + expected={"ok": 1.0}, + msg="killSessions should accept null array element", + ), + CommandTestCase( + "element_null_after_valid", + command=lambda ctx: {"killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)}, None]}, + expected={"ok": 1.0}, + msg="killSessions should accept null element after valid element", + ), +] + +# Property [Stable API V1 Acceptance]: killSessions succeeds with +# apiVersion "1" and non-strict API parameters. +KILLSESSIONS_STABLE_API_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "api_version_1", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with apiVersion 1", + ), + CommandTestCase( + "api_version_1_strict_false", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + "apiStrict": False, + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with apiVersion 1 and apiStrict false", + ), + CommandTestCase( + "api_version_1_deprecation_true", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + "apiDeprecationErrors": True, + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with apiVersion 1 and apiDeprecationErrors true", + ), + CommandTestCase( + "api_version_1_deprecation_false", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + "apiDeprecationErrors": False, + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with apiVersion 1 and apiDeprecationErrors false", + ), +] + +KILLSESSIONS_ACCEPTANCE_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_MAXTIMEMS_ACCEPTANCE_TESTS + + KILLSESSIONS_WRITECONCERN_ACCEPTANCE_TESTS + + KILLSESSIONS_UNRECOGNIZED_FIELD_TESTS + + KILLSESSIONS_READCONCERN_ACCEPTANCE_TESTS + + KILLSESSIONS_NULL_ELEMENT_TESTS + + KILLSESSIONS_STABLE_API_ACCEPTANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_ACCEPTANCE_TESTS)) +def test_killSessions_acceptance(collection, test): + """Test killSessions parameter acceptance.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_core.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_core.py new file mode 100644 index 000000000..3c26b5c75 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_core.py @@ -0,0 +1,213 @@ +"""Tests for killSessions core behavior and comment acceptance.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import CURSOR_NOT_FOUND_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [Random UUID]: killSessions with a non-matching UUID succeeds +# silently without error. +KILLSESSIONS_RANDOM_UUID_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "random_uuid_single", + command=lambda ctx: {"killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)}]}, + expected={"ok": 1.0}, + msg="killSessions should succeed with a random non-matching UUID", + ), + CommandTestCase( + "random_uuid_two", + command=lambda ctx: { + "killSessions": [ + {"id": Binary(uuid.uuid4().bytes, 4)}, + {"id": Binary(uuid.uuid4().bytes, 4)}, + ] + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with two random UUIDs", + ), + CommandTestCase( + "random_uuid_five", + command=lambda ctx: { + "killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)} for _ in range(5)] + }, + expected={"ok": 1.0}, + msg="killSessions should succeed with five random UUIDs", + ), +] + +# Property [Duplicate UUIDs]: duplicate session IDs in the array are +# accepted without error. +KILLSESSIONS_DUPLICATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "duplicate_uuids", + command=lambda ctx: { + "killSessions": [ + {"id": Binary(b"\xde\xad" * 8, subtype=4)}, + {"id": Binary(b"\xde\xad" * 8, subtype=4)}, + ] + }, + expected={"ok": 1.0}, + msg="killSessions should accept duplicate UUIDs without error", + ), +] + +# Property [Large Array]: killSessions handles a large array of session +# identifiers without error. +KILLSESSIONS_LARGE_ARRAY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "large_array_100", + command=lambda ctx: { + "killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)} for _ in range(100)] + }, + expected={"ok": 1.0}, + msg="killSessions should handle 100 session identifiers", + ), +] + +# Property [comment Field Universal Type Acceptance]: all BSON types +# representable by pymongo are accepted for the comment field without +# restriction. +KILLSESSIONS_COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "comment": v, + }, + expected={"ok": 1.0}, + msg=f"killSessions should accept {tid} comment", + ) + for tid, val in [ + ("string", "test comment"), + ("string_empty", ""), + ("int32", 42), + ("int64", Int64(123)), + ("double", 3.14), + ("decimal128", Decimal128("1.5")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("object", {"key": "value"}), + ("object_empty", {}), + ("array", [1, "two", 3.0]), + ("array_empty", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Database Context]: killSessions succeeds when run on +# the admin database (not just the default test database). +KILLSESSIONS_ADMIN_DB_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "admin_database", + command=lambda ctx: {"killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}]}, + expected={"ok": 1.0}, + msg="killSessions should succeed on admin database", + ), +] + +KILLSESSIONS_CORE_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_RANDOM_UUID_TESTS + + KILLSESSIONS_DUPLICATE_TESTS + + KILLSESSIONS_LARGE_ARRAY_TESTS + + KILLSESSIONS_COMMENT_TYPE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_CORE_TESTS)) +def test_killSessions_core(collection, test): + """Test killSessions core behavior.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_ADMIN_DB_TESTS)) +def test_killSessions_admin_db(collection, test): + """Test killSessions on admin database.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + msg=test.msg, + raw_res=True, + ) + + +def test_killSessions_real_session(collection): + """Test killSessions kills a real active session. + + Opens a cursor under the session, kills the session, then verifies + that getMore fails with CursorNotFound — proving the kill closed + the cursor. + """ + client = collection.database.client + db = collection.database + # Use a context manager so the session is always ended, even if an + # assertion or command fails partway through. + with client.start_session() as session: + lsid = session.session_id["id"] + + # Insert enough docs to require a getMore. + collection.insert_many([{"_id": i} for i in range(10)], session=session) + + # Open a cursor with a small batch so the first batch doesn't exhaust it. + find_result = db.command( + {"find": collection.name, "batchSize": 2}, + session=session, + ) + cursor_id = find_result["cursor"]["id"] + + # Kill the session. + execute_command(collection, {"killSessions": [{"id": lsid}]}) + + # getMore on the killed session's cursor should fail with CursorNotFound, + # proving the kill closed the cursor. + get_more_result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + session=session, + ) + assertResult( + get_more_result, + error_code=CURSOR_NOT_FOUND_ERROR, + msg="getMore should fail after session is killed", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_field_type_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_field_type_error.py new file mode 100644 index 000000000..ac81b70a4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_field_type_error.py @@ -0,0 +1,155 @@ +"""Tests for killSessions command field type and Stable API errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + API_STRICT_ERROR, + API_VERSION_ERROR, + API_VERSION_REQUIRED_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [Command Field Type Rejection]: the killSessions command field +# expects an array. All non-array BSON types are rejected. +KILLSESSIONS_FIELD_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"field_type_{tid}", + command=lambda ctx, v=val: {"killSessions": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} as command field value", + ) + for tid, val in [ + ("int32", 1), + ("int32_zero", 0), + ("int32_negative", -1), + ("int64", Int64(1)), + ("double", 1.0), + ("double_zero", 0.0), + ("double_negative", -1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("string", "test"), + ("string_empty", ""), + ("object_empty", {}), + ("object", {"key": "value"}), + ("binary", Binary(b"\x00\x01\x02")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*", "i")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("double_nan", float("nan")), + ("double_inf", float("inf")), + ] +] + [ + # Null produces a missing field error rather than a type mismatch. + CommandTestCase( + "field_type_null", + command=lambda ctx: {"killSessions": None}, + error_code=MISSING_FIELD_ERROR, + msg="killSessions should reject null as command field value", + ), +] + +# Property [Stable API V1 Rejection]: killSessions is NOT in API +# Version 1, so apiStrict true rejects it. +KILLSESSIONS_STABLE_API_STRICT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "api_strict_true", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "1", + "apiStrict": True, + }, + error_code=API_STRICT_ERROR, + msg="killSessions should be rejected with apiStrict true", + ), +] + +# Property [API Version Errors]: invalid apiVersion values and +# missing apiVersion with other API parameters are rejected. +KILLSESSIONS_API_VERSION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "api_version_2", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "2", + }, + error_code=API_VERSION_ERROR, + msg="killSessions should reject apiVersion 2", + ), + CommandTestCase( + "api_version_empty", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiVersion": "", + }, + error_code=API_VERSION_ERROR, + msg="killSessions should reject empty apiVersion", + ), + CommandTestCase( + "api_strict_without_version", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiStrict": True, + }, + error_code=API_VERSION_REQUIRED_ERROR, + msg="killSessions should reject apiStrict without apiVersion", + ), + CommandTestCase( + "api_deprecation_without_version", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "apiDeprecationErrors": True, + }, + error_code=API_VERSION_REQUIRED_ERROR, + msg="killSessions should reject apiDeprecationErrors without apiVersion", + ), +] + +KILLSESSIONS_FIELD_TYPE_AND_API_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_FIELD_TYPE_ERROR_TESTS + + KILLSESSIONS_STABLE_API_STRICT_ERROR_TESTS + + KILLSESSIONS_API_VERSION_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_FIELD_TYPE_AND_API_ERROR_TESTS)) +def test_killSessions_field_type_error(collection, test): + """Test killSessions command field type and Stable API errors.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_maxtimems_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_maxtimems_error.py new file mode 100644 index 000000000..593e90b8b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_maxtimems_error.py @@ -0,0 +1,143 @@ +"""Tests for killSessions maxTimeMS field errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + 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_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_OVERFLOW, +) + +pytestmark = pytest.mark.no_parallel + +# Property [maxTimeMS Type Rejection]: all non-numeric, non-null BSON +# types for maxTimeMS are rejected. +KILLSESSIONS_MAXTIMEMS_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"maxtimems_type_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "maxTimeMS": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} for maxTimeMS", + ) + for tid, val in [ + ("bool_true", True), + ("bool_false", False), + ("string", "1000"), + ("object", {"a": 1}), + ("array", [1, 2]), + ("objectid", ObjectId()), + ("binary", Binary(b"\x01")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [maxTimeMS Value Rejection]: values outside the valid range +# or not representable as whole integers are rejected. +KILLSESSIONS_MAXTIMEMS_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"maxtimems_value_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "maxTimeMS": v, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"killSessions should reject {tid} maxTimeMS", + ) + for tid, val in [ + # Fractional values. + ("fractional_double", 1.5), + ("fractional_decimal128", DECIMAL128_ONE_AND_HALF), + # Double NaN and Infinity. + ("double_nan", FLOAT_NAN), + ("double_negative_nan", FLOAT_NEGATIVE_NAN), + ("double_positive_infinity", FLOAT_INFINITY), + ("double_negative_infinity", FLOAT_NEGATIVE_INFINITY), + # Decimal128 NaN and Infinity. + ("decimal128_nan", DECIMAL128_NAN), + ("decimal128_negative_nan", DECIMAL128_NEGATIVE_NAN), + ("decimal128_positive_infinity", DECIMAL128_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + [ + CommandTestCase( + f"maxtimems_value_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "maxTimeMS": v, + }, + error_code=BAD_VALUE_ERROR, + msg=f"killSessions should reject {tid} maxTimeMS", + ) + for tid, val in [ + # Below lower boundary. + ("int32_negative", -1), + ("int64_negative", Int64(-1)), + ("double_negative", -1.0), + ("decimal128_negative", Decimal128("-1")), + # Above upper boundary. + ("int32_overflow", INT32_OVERFLOW), + ("int64_overflow", Int64(INT32_OVERFLOW)), + ("double_overflow", float(INT32_OVERFLOW)), + ("decimal128_overflow", Decimal128(str(INT32_OVERFLOW))), + ] +] + +KILLSESSIONS_MAXTIMEMS_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_MAXTIMEMS_TYPE_ERROR_TESTS + KILLSESSIONS_MAXTIMEMS_VALUE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_MAXTIMEMS_ERROR_TESTS)) +def test_killSessions_maxtimems_error(collection, test): + """Test killSessions maxTimeMS field errors.""" + 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/sessions/commands/killSessions/test_killSessions_options_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_options_error.py new file mode 100644 index 000000000..e68411bbe --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_options_error.py @@ -0,0 +1,103 @@ +"""Tests for killSessions writeConcern field errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [writeConcern Type Rejection]: all non-document, non-null BSON +# types for the writeConcern field are rejected. +KILLSESSIONS_WRITECONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"writeconcern_type_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "writeConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} writeConcern", + ) + for tid, val in [ + ("string", "majority"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", []), + ("array_nonempty", [1, 2]), + ("binary", Binary(b"data")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [writeConcern Document Rejection]: document values for +# writeConcern are rejected because the command does not support it. +KILLSESSIONS_WRITECONCERN_DOC_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"writeconcern_doc_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "writeConcern": v, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=f"killSessions should reject writeConcern {tid}", + ) + for tid, val in [ + ("empty", {}), + ("w_1", {"w": 1}), + ("w_majority", {"w": "majority"}), + ("j_true", {"j": True}), + ] +] + +KILLSESSIONS_OPTIONS_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_WRITECONCERN_TYPE_ERROR_TESTS + KILLSESSIONS_WRITECONCERN_DOC_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_OPTIONS_ERROR_TESTS)) +def test_killSessions_options_error(collection, test): + """Test killSessions writeConcern field errors.""" + 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/sessions/commands/killSessions/test_killSessions_readconcern_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_readconcern_error.py new file mode 100644 index 000000000..6d646baa3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_readconcern_error.py @@ -0,0 +1,205 @@ +"""Tests for killSessions readConcern field errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + ILLEGAL_OPERATION_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [readConcern Level Rejection]: readConcern with levels other +# than "local" is rejected. +KILLSESSIONS_READCONCERN_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_majority", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "majority"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killSessions should reject readConcern level majority", + ), + CommandTestCase( + "readconcern_available", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "available"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killSessions should reject readConcern level available", + ), + CommandTestCase( + "readconcern_linearizable", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "linearizable"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killSessions should reject readConcern level linearizable", + ), + CommandTestCase( + "readconcern_snapshot", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "snapshot"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killSessions should reject readConcern level snapshot", + ), + CommandTestCase( + "readconcern_level_invalid_name", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="killSessions should reject unrecognized readConcern level name", + ), + CommandTestCase( + "readconcern_level_empty_string", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": ""}, + }, + error_code=BAD_VALUE_ERROR, + msg="killSessions should reject empty string readConcern level", + ), +] + +# Property [readConcern Type Rejection]: all non-document, non-null BSON +# types for the readConcern field are rejected. +KILLSESSIONS_READCONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"readconcern_type_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} readConcern", + ) + for tid, val in [ + ("string", "local"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2]), + ("binary", Binary(b"data")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Subfield Rejection]: invalid subfields, unknown +# fields, and wrong types within the readConcern document are rejected. +KILLSESSIONS_READCONCERN_SUBFIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_unknown_subfield", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"bogusField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killSessions should reject unknown subfield in readConcern", + ), + CommandTestCase( + "readconcern_afterclustertime_valid_timestamp", + command=lambda ctx: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"afterClusterTime": Timestamp(1, 1)}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + # afterClusterTime is rejected only where replication-dependent read + # concern is unavailable (standalone); a replica set accepts it. + marks=(pytest.mark.requires(cluster_read_concern=False),), + msg="killSessions should reject afterClusterTime in readConcern on a standalone server", + ), +] + +# Property [readConcern Level Type Rejection]: all non-string, non-null +# types for the readConcern level field are rejected. +KILLSESSIONS_READCONCERN_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"readconcern_level_type_{tid}", + command=lambda ctx, v=val: { + "killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=4)}], + "readConcern": {"level": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} type for readConcern level", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", ["local"]), + ("document", {"a": 1}), + ("binary", Binary(b"local")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex("local")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +KILLSESSIONS_READCONCERN_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_READCONCERN_LEVEL_ERROR_TESTS + + KILLSESSIONS_READCONCERN_TYPE_ERROR_TESTS + + KILLSESSIONS_READCONCERN_SUBFIELD_ERROR_TESTS + + KILLSESSIONS_READCONCERN_LEVEL_TYPE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_READCONCERN_ERROR_TESTS)) +def test_killSessions_readconcern_error(collection, test): + """Test killSessions readConcern field errors.""" + 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/sessions/commands/killSessions/test_killSessions_sessions_type_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_sessions_type_error.py new file mode 100644 index 000000000..6fdb6f678 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/killSessions/test_killSessions_sessions_type_error.py @@ -0,0 +1,199 @@ +"""Tests for killSessions array element and session ID type errors.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_UUID_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.no_parallel + +# Property [Non-Document Array Elements]: array elements that are not +# documents are rejected. +KILLSESSIONS_ELEMENT_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"element_type_{tid}", + command=lambda ctx, v=val: {"killSessions": [v]}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} array element", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("string", "string"), + ("bool_true", True), + ("bool_false", False), + ("array", []), + ("binary", Binary(b"\x00")), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Invalid Document Elements]: documents missing the required +# id field or containing wrong field names are rejected. +KILLSESSIONS_INVALID_DOC_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "element_empty_doc", + command=lambda ctx: {"killSessions": [{}]}, + error_code=MISSING_FIELD_ERROR, + msg="killSessions should reject empty document element", + ), + CommandTestCase( + "element_wrong_field", + command=lambda ctx: {"killSessions": [{"notId": Binary(uuid.uuid4().bytes, 4)}]}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killSessions should reject document with wrong field name", + ), + CommandTestCase( + "element_unrelated_fields", + command=lambda ctx: {"killSessions": [{"foo": "bar"}]}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killSessions should reject document with unrelated fields", + ), +] + +# Property [Mixed Valid and Invalid Elements]: an invalid element after +# a valid one is still rejected. +KILLSESSIONS_MIXED_ELEMENT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "mixed_valid_then_int", + command=lambda ctx: {"killSessions": [{"id": Binary(uuid.uuid4().bytes, 4)}, 1]}, + error_code=TYPE_MISMATCH_ERROR, + msg="killSessions should reject int element after valid element", + ), +] + +# Property [Session ID Type Rejection]: the id field must be a UUID +# (Binary subtype 4). All other types are rejected. +KILLSESSIONS_ID_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"id_type_{tid}", + command=lambda ctx, v=val: {"killSessions": [{"id": v}]}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killSessions should reject {tid} as session id", + ) + for tid, val in [ + ("string", "not-a-uuid-string"), + ("int32", 12345), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", []), + ("object", {}), + ("objectid", ObjectId()), + ("binary_subtype0", Binary(b"\xde\xad" * 8, subtype=0)), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + [ + # Null id produces a missing field error rather than a type mismatch. + CommandTestCase( + "id_type_null", + command=lambda ctx: {"killSessions": [{"id": None}]}, + error_code=MISSING_FIELD_ERROR, + msg="killSessions should reject null as session id", + ), +] + +# Property [UUID Binary Size Rejection]: Binary subtype 4 values that are +# not exactly 16 bytes are rejected with INVALID_UUID_ERROR. +KILLSESSIONS_UUID_SIZE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "uuid_too_short", + command=lambda ctx: {"killSessions": [{"id": Binary(b"\x00" * 8, subtype=4)}]}, + error_code=INVALID_UUID_ERROR, + msg="killSessions should reject Binary subtype 4 with 8 bytes", + ), + CommandTestCase( + "uuid_too_long", + command=lambda ctx: {"killSessions": [{"id": Binary(b"\x00" * 32, subtype=4)}]}, + error_code=INVALID_UUID_ERROR, + msg="killSessions should reject Binary subtype 4 with 32 bytes", + ), +] + +# Property [Extra Fields in Session Identifier]: documents with extra +# fields alongside id are rejected. +KILLSESSIONS_EXTRA_FIELDS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "extra_field_alongside_id", + command=lambda ctx: {"killSessions": [{"id": Binary(uuid.uuid4().bytes, 4), "extra": 1}]}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killSessions should reject extra fields alongside id", + ), +] + +# Property [Binary Subtype 3]: Binary subtype 3 (old UUID) is rejected +# for the id field. +KILLSESSIONS_BINARY_SUBTYPE3_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "id_binary_subtype3", + command=lambda ctx: {"killSessions": [{"id": Binary(b"\xde\xad" * 8, subtype=3)}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="killSessions should reject Binary subtype 3 as session id", + ), +] + +KILLSESSIONS_SESSIONS_TYPE_ERROR_TESTS: list[CommandTestCase] = ( + KILLSESSIONS_ELEMENT_TYPE_ERROR_TESTS + + KILLSESSIONS_INVALID_DOC_ERROR_TESTS + + KILLSESSIONS_MIXED_ELEMENT_ERROR_TESTS + + KILLSESSIONS_ID_TYPE_ERROR_TESTS + + KILLSESSIONS_UUID_SIZE_ERROR_TESTS + + KILLSESSIONS_EXTRA_FIELDS_TESTS + + KILLSESSIONS_BINARY_SUBTYPE3_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLSESSIONS_SESSIONS_TYPE_ERROR_TESTS)) +def test_killSessions_sessions_type_error(collection, test): + """Test killSessions array element and session ID type errors.""" + 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, + ) From 304192f5e9b2f700eb9e7b3abdc6904a2aa55125 Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Mon, 6 Jul 2026 14:03:21 -0700 Subject: [PATCH 40/51] Add update tests for pullAll (#602) Signed-off-by: Victor [C] Tsang --- .../test_operations_update_array_ops.py | 81 +++++++ .../operator/update/array/pullAll/__init__.py | 0 .../pullAll/test_pullAll_argument_handling.py | 61 +++++ .../test_pullAll_bson_type_validation.py | 107 +++++++++ .../pullAll/test_pullAll_core_behavior.py | 91 ++++++++ .../array/pullAll/test_pullAll_data_types.py | 216 ++++++++++++++++++ .../pullAll/test_pullAll_nested_fields.py | 90 ++++++++ .../test_pullAll_update_integration.py | 131 +++++++++++ .../pullAll/test_pullAll_value_matching.py | 115 ++++++++++ 9 files changed, 892 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py diff --git a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py index e47e908e2..d18a75707 100644 --- a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py +++ b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py @@ -117,6 +117,54 @@ expected={"ok": 1.0, "n": 1, "nModified": 1}, msg="$pullAll with strength 2 should remove case-variant elements", ), + CommandTestCase( + "pullall_accent_insensitive", + docs=[{"_id": 1, "tags": ["cafe", "caf\u00e9", "tea"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["cafe"]}}, + "collation": {"locale": "en", "strength": 1}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll with strength 1 should remove accent-variant elements", + ), + CommandTestCase( + "pullall_strength3_no_case_match", + docs=[{"_id": 1, "tags": ["Apple", "apple", "banana"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["Apple"]}}, + "collation": {"locale": "en", "strength": 3}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll with strength 3 should not match case variants", + ), + CommandTestCase( + "pullall_numeric_ordering", + docs=[{"_id": 1, "vals": ["1", "2", "10", "20"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"vals": ["10"]}}, + "collation": {"locale": "en", "numericOrdering": True}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll with numericOrdering should match numeric string values", + ), CommandTestCase( "pullall_no_collation_binary", docs=[{"_id": 1, "tags": ["Apple", "apple", "banana"]}], @@ -132,6 +180,39 @@ expected={"ok": 1.0, "n": 1, "nModified": 1}, msg="$pullAll without collation should use binary comparison", ), + CommandTestCase( + "pullall_collection_default_collation", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "tags": ["Apple", "banana"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["apple"]}}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll should inherit collection default collation", + ), + CommandTestCase( + "pullall_explicit_overrides_collection_default", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "tags": ["Apple", "apple", "banana"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["apple"]}}, + "collation": {"locale": "en", "strength": 3}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll explicit collation should override collection default", + ), ] # Property [AddToSet with Collation]: $addToSet uses collation to determine diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/__init__.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py new file mode 100644 index 000000000..eb7cd1dd9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py @@ -0,0 +1,61 @@ +"""Tests for $pullAll argument handling. + +Covers: null/missing field handling, empty operand, multiple fields. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +SUCCESS_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "null_in_values_removes_null", + setup_docs=[{"_id": 1, "a": [1, None, 2, None, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [None]}}, + expected={"_id": 1, "a": [1, 2, 3]}, + msg="Should remove null elements from array", + ), + UpdateTestCase( + "nonexistent_field_noop", + setup_docs=[{"_id": 1, "b": "other"}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2]}}, + expected={"_id": 1, "b": "other"}, + msg="Should be no-op when field does not exist", + ), + UpdateTestCase( + "multiple_fields", + setup_docs=[{"_id": 1, "a": [1, 2, 3], "b": ["x", "y", "z"]}], + query={"_id": 1}, + update={"$pullAll": {"a": [2], "b": ["y"]}}, + expected={"_id": 1, "a": [1, 3], "b": ["x", "z"]}, + msg="Should process multiple fields independently", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SUCCESS_TESTS)) +def test_pullAll_argument_handling(collection, test: UpdateTestCase): + """Test $pullAll null values, missing fields, and multiple fields handling.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) + + +def test_pullAll_empty_operand_noop(collection): + """Test $pullAll with empty operand expression {} is a no-op.""" + collection.insert_one({"_id": 1, "a": [1, 2, 3]}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$pullAll": {}}}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "a": [1, 2, 3]}], msg="Empty $pullAll should be no-op") diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py new file mode 100644 index 000000000..34df97c8b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py @@ -0,0 +1,107 @@ +"""Tests for $pullAll BSON type validation. + +Verifies that $pullAll rejects non-array target field types with error code 2, +rejects non-array argument types with error code 2, and can match any BSON type +as values to remove from an array. +""" + +import pytest +from bson import Binary + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command + +PULLALL_PARAMS = [ + BsonTypeTestCase( + id="target_field", + msg="$pullAll should error when the document field it targets is not an array", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": [2]}], + valid_inputs={BsonType.ARRAY: [1, 2]}, + ), + BsonTypeTestCase( + id="argument", + msg="$pullAll should reject non-array argument types", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": [1, 2, 3]}], + valid_inputs={BsonType.ARRAY: [99]}, + ), + BsonTypeTestCase( + id="value_element", + msg="$pullAll should accept any BSON type as value to match and remove", + valid_types=list(BsonType), + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": []}], + valid_inputs={BsonType.BIN_DATA: Binary(b"\x00\x01\x02", 128)}, + ), +] + + +def _setup_doc(spec, sample_value) -> dict: + """Build the setup document based on which aspect is being tested.""" + if spec.id == "target_field": + return {"_id": 1, "arr": sample_value} + if spec.id == "argument": + return {"_id": 1, "arr": [1, 2, 3]} + return {"_id": 1, "arr": [sample_value]} + + +def _build_update(spec, sample_value) -> dict: + """Build the update command based on which aspect is being tested.""" + if spec.id == "target_field": + return {"$pullAll": {"arr": [1]}} + if spec.id == "argument": + return {"$pullAll": {"arr": sample_value}} + return {"$pullAll": {"arr": [sample_value]}} + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(PULLALL_PARAMS) +) +def test_pullAll_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test $pullAll rejects invalid BSON types with error.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"$pullAll should reject {bson_type.value} for {spec.id}", + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(PULLALL_PARAMS) +) +def test_pullAll_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test $pullAll accepts valid BSON types.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + result, spec.expected, msg=f"$pullAll should accept {bson_type.value} for {spec.id}" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py new file mode 100644 index 000000000..8a78e4f9f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py @@ -0,0 +1,91 @@ +"""Tests for $pullAll core behavior. + +Covers: removal of all instances, empty values list, all elements removed, +empty array target, duplicate values in values list, values not present. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +PULLALL_CORE_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "removes_all_instances", + setup_docs=[{"_id": 1, "a": [1, 2, 3, 1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 3]}}, + expected={"_id": 1, "a": [2, 2]}, + msg="Should remove all instances of each specified value", + ), + UpdateTestCase( + "removes_multiple_occurrences", + setup_docs=[{"_id": 1, "a": [5, 5, 5, 6, 5]}], + query={"_id": 1}, + update={"$pullAll": {"a": [5]}}, + expected={"_id": 1, "a": [6]}, + msg="Should remove multiple occurrences of same value", + ), + UpdateTestCase( + "values_not_present_noop", + setup_docs=[{"_id": 1, "a": [1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [99, 100]}}, + expected={"_id": 1, "a": [1, 2, 3]}, + msg="Should be no-op when values not present", + ), + UpdateTestCase( + "some_values_present_some_not", + setup_docs=[{"_id": 1, "a": [1, 2, 3, 4]}], + query={"_id": 1}, + update={"$pullAll": {"a": [2, 4, 99]}}, + expected={"_id": 1, "a": [1, 3]}, + msg="Should remove only matching values", + ), + UpdateTestCase( + "empty_values_list_noop", + setup_docs=[{"_id": 1, "a": [1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": []}}, + expected={"_id": 1, "a": [1, 2, 3]}, + msg="Should be no-op with empty values list", + ), + UpdateTestCase( + "removes_all_leaves_empty_array", + setup_docs=[{"_id": 1, "a": [1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2, 3]}}, + expected={"_id": 1, "a": []}, + msg="Should leave empty array when all elements removed", + ), + UpdateTestCase( + "empty_array_noop", + setup_docs=[{"_id": 1, "a": []}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2]}}, + expected={"_id": 1, "a": []}, + msg="Should be no-op on empty array", + ), + UpdateTestCase( + "duplicate_values_in_values_list", + setup_docs=[{"_id": 1, "a": [1, 2, 3, 1]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 1, 1]}}, + expected={"_id": 1, "a": [2, 3]}, + msg="Duplicate values in values list should behave same as single", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PULLALL_CORE_TESTS)) +def test_pullAll_core_behavior(collection, test: UpdateTestCase): + """Test $pullAll core removal behavior.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py new file mode 100644 index 000000000..fba090d1d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py @@ -0,0 +1,216 @@ +"""Tests for $pullAll data type matching semantics. + +Covers: numeric equivalence, BSON type distinction, NaN/Infinity handling, +cross-type NaN/Infinity/negative-zero equivalence. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, +) + +NUMERIC_EQUIVALENCE_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "mixed_types_in_values_list", + setup_docs=[{"_id": 1, "a": [1, 2.0, Int64(3), Decimal128("4")]}], + query={"_id": 1}, + update={"$pullAll": {"a": [Int64(1), 2, Decimal128("3"), 4.0]}}, + expected={"_id": 1, "a": []}, + msg="Should remove with mixed numeric types in values list", + ), +] + +BSON_TYPE_DISTINCTION_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "false_does_not_remove_int_zero", + setup_docs=[{"_id": 1, "a": [0, False]}], + query={"_id": 1}, + update={"$pullAll": {"a": [False]}}, + expected={"_id": 1, "a": [0]}, + msg="false should NOT remove int(0) — distinct BSON types", + ), + UpdateTestCase( + "true_does_not_remove_int_one", + setup_docs=[{"_id": 1, "a": [1, True]}], + query={"_id": 1}, + update={"$pullAll": {"a": [True]}}, + expected={"_id": 1, "a": [1]}, + msg="true should NOT remove int(1) — distinct BSON types", + ), + UpdateTestCase( + "null_removes_only_null", + setup_docs=[{"_id": 1, "a": [None, 0, "", False]}], + query={"_id": 1}, + update={"$pullAll": {"a": [None]}}, + expected={"_id": 1, "a": [0, "", False]}, + msg="Should remove only null elements", + ), + UpdateTestCase( + "empty_string_does_not_remove_null", + setup_docs=[{"_id": 1, "a": [None, ""]}], + query={"_id": 1}, + update={"$pullAll": {"a": [""]}}, + expected={"_id": 1, "a": [None]}, + msg="empty string should NOT remove null — distinct types", + ), +] + +NAN_INFINITY_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "float_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [FLOAT_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove NaN elements", + ), + UpdateTestCase( + "decimal128_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 NaN elements", + ), + UpdateTestCase( + "infinity_removes_infinity", + setup_docs=[{"_id": 1, "a": [FLOAT_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Infinity elements", + ), + UpdateTestCase( + "neg_infinity_removes_neg_infinity", + setup_docs=[{"_id": 1, "a": [FLOAT_NEGATIVE_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove -Infinity elements", + ), + UpdateTestCase( + "neg_zero_removes_zero", + setup_docs=[{"_id": 1, "a": [0.0, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DOUBLE_NEGATIVE_ZERO]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove 0.0 via -0.0 (numeric equivalence)", + ), + UpdateTestCase( + "decimal128_neg_zero_removes_zero", + setup_docs=[{"_id": 1, "a": [Decimal128("0"), 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NEGATIVE_ZERO]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 0 via Decimal128 -0 (numeric equivalence)", + ), + UpdateTestCase( + "float_nan_matches_decimal128_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float NaN should match Decimal128 NaN (cross-type NaN equivalence)", + ), + UpdateTestCase( + "float_inf_matches_decimal128_inf", + setup_docs=[{"_id": 1, "a": [DECIMAL128_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float Infinity should match Decimal128 Infinity (cross-type equivalence)", + ), + UpdateTestCase( + "decimal128_neg_infinity_removes_self", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NEGATIVE_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 -Infinity elements", + ), + UpdateTestCase( + "float_neg_inf_matches_decimal128_neg_inf", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float -Infinity should match Decimal128 -Infinity (cross-type equivalence)", + ), + UpdateTestCase( + "float_neg_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [FLOAT_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove float -NaN elements", + ), + UpdateTestCase( + "decimal128_neg_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NEGATIVE_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 -NaN elements", + ), + UpdateTestCase( + "float_nan_matches_float_neg_nan", + setup_docs=[{"_id": 1, "a": [FLOAT_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float NaN should match float -NaN (NaN sign equivalence)", + ), + UpdateTestCase( + "decimal128_nan_matches_decimal128_neg_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Decimal128 NaN should match Decimal128 -NaN (NaN sign equivalence)", + ), + UpdateTestCase( + "float_neg_nan_matches_decimal128_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float -NaN should match Decimal128 NaN (cross-type NaN equivalence)", + ), + UpdateTestCase( + "float_neg_zero_removes_decimal128_zero", + setup_docs=[{"_id": 1, "a": [Decimal128("0"), 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DOUBLE_NEGATIVE_ZERO]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float -0.0 should remove Decimal128 0 (cross-type negative zero equivalence)", + ), +] + +ALL_TESTS = NUMERIC_EQUIVALENCE_TESTS + BSON_TYPE_DISTINCTION_TESTS + NAN_INFINITY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_pullAll_data_type_matching(collection, test: UpdateTestCase): + """Test $pullAll data type matching semantics.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py new file mode 100644 index 000000000..f4e14443b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py @@ -0,0 +1,90 @@ +"""Tests for $pullAll with dot notation and nested fields. + +Covers: deeply nested dot notation paths, intermediate path behavior, +positional operator, non-array argument rejection. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +NESTED_FIELD_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "dot_notation_deep", + setup_docs=[{"_id": 1, "a": {"b": {"c": [10, 20, 30]}}}], + query={"_id": 1}, + update={"$pullAll": {"a.b.c": [20]}}, + expected={"_id": 1, "a": {"b": {"c": [10, 30]}}}, + msg="Should remove from deeply nested array", + ), + UpdateTestCase( + "intermediate_does_not_exist_noop", + setup_docs=[{"_id": 1, "x": 1}], + query={"_id": 1}, + update={"$pullAll": {"a.b": [1]}}, + expected={"_id": 1, "x": 1}, + msg="Should be no-op when intermediate path does not exist", + ), + UpdateTestCase( + "dot_notation_array_index", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [4, 5, 6]}]}], + query={"_id": 1}, + update={"$pullAll": {"a.0.b": [2, 3]}}, + expected={"_id": 1, "a": [{"b": [1]}, {"b": [4, 5, 6]}]}, + msg="Should pull from specific array element via numeric index in dot notation", + ), + UpdateTestCase( + "array_intermediate_no_traversal", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [2, 3, 4]}]}], + query={"_id": 1}, + update={"$pullAll": {"a.b": [2]}}, + expected={"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [2, 3, 4]}]}, + msg="Should be no-op when intermediate is an array without explicit index", + ), + UpdateTestCase( + "positional_operator", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [2, 4, 5]}]}], + query={"a.b": 2}, + update={"$pullAll": {"a.$.b": [2]}}, + expected={"_id": 1, "a": [{"b": [1, 3]}, {"b": [2, 4, 5]}]}, + msg="Should pull from first matched array element via positional operator", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NESTED_FIELD_TESTS)) +def test_pullAll_nested_fields(collection, test: UpdateTestCase): + """Test $pullAll with dot notation and nested fields.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) + + +def test_pullAll_nested_object_argument_rejected(collection): + """Test $pullAll rejects object argument on nested path.""" + collection.insert_one({"_id": 1, "a": {"b": {"c": [10, 20, 30]}}}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"a.b.c": [20], "a.b": {"c": [10, 20, 30]}}}, + } + ], + }, + ) + assertFailureCode( + result, + BAD_VALUE_ERROR, + msg="$pullAll should reject object argument on nested path", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py new file mode 100644 index 000000000..0d6500e1d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py @@ -0,0 +1,131 @@ +"""Tests for $pullAll update command integration. + +Covers: updateOne, updateMany, bulkWrite, upsert behavior, large arrays. +""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command + + +def test_pullAll_updateOne(collection): + """Test $pullAll with updateOne only modifies one document when multiple match.""" + collection.insert_many( + [ + {"q": 1, "a": [1, 2, 3]}, + {"q": 1, "a": [1, 2, 3]}, + ] + ) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"q": 1}, "u": {"$pullAll": {"a": [2, 3]}}}]}, + ) + assertSuccessPartial( + result, {"n": 1, "nModified": 1, "ok": 1.0}, msg="updateOne should succeed" + ) + + +def test_pullAll_updateMany(collection): + """Test $pullAll with updateMany processes each matched document independently.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2, 3]}, + {"_id": 2, "a": [2, 3, 4]}, + {"_id": 3, "a": [5, 6, 7]}, + ] + ) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$pullAll": {"a": [2, 3]}}, "multi": True}], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [ + {"_id": 1, "a": [1]}, + {"_id": 2, "a": [4]}, + {"_id": 3, "a": [5, 6, 7]}, + ], + msg="updateMany should process each doc independently", + ) + + +def test_pullAll_bulkWrite(collection): + """Test $pullAll in bulkWrite updates multiple documents correctly.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2, 3]}, + {"_id": 2, "a": [4, 5, 6]}, + ] + ) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$pullAll": {"a": [1]}}}, + {"q": {"_id": 2}, "u": {"$pullAll": {"a": [5, 6]}}}, + ], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [{"_id": 1, "a": [2, 3]}, {"_id": 2, "a": [4]}], + msg="bulkWrite should update both docs correctly", + ) + + +def test_pullAll_upsert_creates_doc_without_array(collection): + """Test $pullAll with upsert:true creates doc without array field.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 99}, "u": {"$pullAll": {"a": [1, 2]}}, "upsert": True}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 99}}) + assertSuccess(result, [{"_id": 99}], msg="Upsert should create doc without array field") + + +def test_pullAll_large_array(collection): + """Test $pullAll removing many values from a large array.""" + large_array = list(range(200)) + collection.insert_one({"_id": 1, "a": large_array}) + values_to_remove = list(range(0, 200, 2)) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$pullAll": {"a": values_to_remove}}}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 1, "a": list(range(1, 200, 2))}], + msg="Should remove even numbers from large array", + ) + + +def test_pullAll_large_values_list(collection): + """Test $pullAll with large values list (100+ values).""" + collection.insert_one({"_id": 1, "a": list(range(50))}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$pullAll": {"a": list(range(150))}}}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + result, [{"_id": 1, "a": []}], msg="Should remove all elements with large values list" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py new file mode 100644 index 000000000..5bdfe5953 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py @@ -0,0 +1,115 @@ +"""Tests for $pullAll value matching behavior. + +Covers: array element matching (order-sensitive), document matching +(field-order-sensitive), input correlation, mixed-type exact matching. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +PULLALL_VALUE_MATCHING_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "array_exact_match_removes", + setup_docs=[{"_id": 1, "a": [[1, 2, 3], [4, 5, 6]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[1, 2, 3]]}}, + expected={"_id": 1, "a": [[4, 5, 6]]}, + msg="Should remove exact array matches", + ), + UpdateTestCase( + "array_different_order_no_remove", + setup_docs=[{"_id": 1, "a": [[1, 2, 3], [4, 5, 6]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[3, 2, 1]]}}, + expected={"_id": 1, "a": [[1, 2, 3], [4, 5, 6]]}, + msg="Should NOT remove array with different order", + ), + UpdateTestCase( + "values_list_removes_individual_not_subarray", + setup_docs=[{"_id": 1, "a": [1, 2, 3, [1, 2, 3]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2, 3]}}, + expected={"_id": 1, "a": [[1, 2, 3]]}, + msg="Should remove individual elements, not the subarray", + ), + UpdateTestCase( + "doc_exact_match_removes", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2}, {"c": 3}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"a": 1, "b": 2}]}}, + expected={"_id": 1, "a": [{"c": 3}]}, + msg="Should remove document with exact field order match", + ), + UpdateTestCase( + "doc_different_field_order_no_remove", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2}, {"c": 3}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"b": 2, "a": 1}]}}, + expected={"_id": 1, "a": [{"a": 1, "b": 2}, {"c": 3}]}, + msg="Should NOT remove document with different field order (unlike $pull)", + ), + UpdateTestCase( + "nested_doc_field_order_must_match", + setup_docs=[{"_id": 1, "a": [{"x": {"a": 1, "b": 2}}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"x": {"b": 2, "a": 1}}]}}, + expected={"_id": 1, "a": [{"x": {"a": 1, "b": 2}}]}, + msg="Nested document field order must match at all levels", + ), + UpdateTestCase( + "nested_doc_exact_match_removes", + setup_docs=[{"_id": 1, "a": [{"x": {"a": 1, "b": 2}}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"x": {"a": 1, "b": 2}}]}}, + expected={"_id": 1, "a": []}, + msg="Should remove nested document with exact match", + ), + UpdateTestCase( + "only_exact_field_order_removed", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2}, {"b": 2, "a": 1}, {"a": 1, "b": 2}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"a": 1, "b": 2}]}}, + expected={"_id": 1, "a": [{"b": 2, "a": 1}]}, + msg="Should only remove exact field-order matches", + ), + UpdateTestCase( + "only_exact_element_order_removed", + setup_docs=[{"_id": 1, "a": [[1, 2], [2, 1], [1, 2]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[1, 2]]}}, + expected={"_id": 1, "a": [[2, 1]]}, + msg="Should only remove exact element-order matches", + ), + UpdateTestCase( + "array_values_mixed_types_exact_match", + setup_docs=[{"_id": 1, "a": [[1, "two", True], [True, "two", 1]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[1, "two", True]]}}, + expected={"_id": 1, "a": [[True, "two", 1]]}, + msg="Should use exact match for array values with mixed types", + ), + UpdateTestCase( + "partial_doc_does_not_match", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2, "c": 3}, {"d": 4}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"a": 1, "b": 2}]}}, + expected={"_id": 1, "a": [{"a": 1, "b": 2, "c": 3}, {"d": 4}]}, + msg="Partial document should NOT match (unlike $pull which does subset matching)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PULLALL_VALUE_MATCHING_TESTS)) +def test_pullAll_value_matching(collection, test: UpdateTestCase): + """Test $pullAll value matching semantics.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) From 608242131d7e50cf1b979ba306765aa292eea1d2 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:41:40 -0700 Subject: [PATCH 41/51] abort Transaction tests (#600) Signed-off-by: Alina (Xi) Li --- .../commands/abortTransaction/__init__.py | 0 .../test_abortTransaction_atomicity.py | 27 ++ .../test_abortTransaction_autocommit_error.py | 116 +++++++++ ...rtTransaction_cannot_commit_after_abort.py | 54 ++++ .../test_abortTransaction_comment.py | 145 +++++++++++ .../test_abortTransaction_core.py | 64 +++++ .../test_abortTransaction_core_error.py | 94 +++++++ ...t_abortTransaction_failed_op_blocks_txn.py | 37 +++ .../test_abortTransaction_field_types.py | 177 +++++++++++++ .../test_abortTransaction_recovers_session.py | 35 +++ .../test_abortTransaction_txn_number_error.py | 131 ++++++++++ ...rtTransaction_unrecognized_fields_error.py | 106 ++++++++ .../test_abortTransaction_writeconcern.py | 194 ++++++++++++++ ...est_abortTransaction_writeconcern_error.py | 239 ++++++++++++++++++ documentdb_tests/framework/error_codes.py | 4 + 15 files changed, 1423 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_atomicity.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_autocommit_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_cannot_commit_after_abort.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_comment.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_failed_op_blocks_txn.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_field_types.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_recovers_session.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_txn_number_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_unrecognized_fields_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern_error.py diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/__init__.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_atomicity.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_atomicity.py new file mode 100644 index 000000000..39ce445ca --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_atomicity.py @@ -0,0 +1,27 @@ +"""Test cross-collection atomicity of an aborted transaction. + +Writes to more than one collection in a single transaction are rolled back +atomically: after abort, neither collection retains its write. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_abort_rolls_back_writes_across_collections(collection): + """Aborting rolls back writes made to a second collection in the same txn.""" + client = collection.database.client + other = collection.database[f"{collection.name}_other"] + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + other.insert_one({"_id": 2}, session=session) + session.abort_transaction() + readback = execute_command(collection, {"find": other.name, "filter": {}}) + assertSuccess(readback, []) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_autocommit_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_autocommit_error.py new file mode 100644 index 000000000..e471c64f2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_autocommit_error.py @@ -0,0 +1,116 @@ +"""Tests for abortTransaction autocommit parameter validation. + +Validates type and value acceptance for the autocommit parameter. Per the +MongoDB documentation, autocommit must be literal boolean false. Boolean true +produces InvalidOptions, non-boolean types produce TypeMismatch, and null is +treated as omitted (falls through to NoSuchTransaction). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [autocommit Boolean Values]: autocommit accepts only boolean values. +AUTOCOMMIT_BOOLEAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_bool_false", + command={"abortTransaction": 1, "autocommit": False}, + error_code=INVALID_OPTIONS_ERROR, + msg="abortTransaction should reject autocommit:false outside txn with InvalidOptions", + ), + CommandTestCase( + "autocommit_bool_true", + command={"abortTransaction": 1, "autocommit": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="abortTransaction should reject autocommit:true with InvalidOptions", + ), +] + +# Property [autocommit Type Strictness]: non-boolean types are rejected with TypeMismatch. +AUTOCOMMIT_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_int32_zero", + command={"abortTransaction": 1, "autocommit": 0}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:0 (int32) as wrong type", + ), + CommandTestCase( + "autocommit_int32_one", + command={"abortTransaction": 1, "autocommit": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:1 (int32) as wrong type", + ), + CommandTestCase( + "autocommit_int64_zero", + command={"abortTransaction": 1, "autocommit": Int64(0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:Int64(0) as wrong type", + ), + CommandTestCase( + "autocommit_double_zero", + command={"abortTransaction": 1, "autocommit": 0.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:0.0 as wrong type", + ), + CommandTestCase( + "autocommit_decimal128_zero", + command={"abortTransaction": 1, "autocommit": Decimal128("0")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:Decimal128('0') as wrong type", + ), + CommandTestCase( + "autocommit_string", + command={"abortTransaction": 1, "autocommit": "false"}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:'false' (string) as wrong type", + ), + CommandTestCase( + "autocommit_object", + command={"abortTransaction": 1, "autocommit": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:{} (object) as wrong type", + ), + CommandTestCase( + "autocommit_array", + command={"abortTransaction": 1, "autocommit": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject autocommit:[] (array) as wrong type", + ), +] + +# Property [autocommit Null Handling]: null autocommit is treated as omitted. +AUTOCOMMIT_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_null", + command={"abortTransaction": 1, "autocommit": None}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should treat autocommit:null as omitted", + ), +] + +AUTOCOMMIT_TESTS: list[CommandTestCase] = ( + AUTOCOMMIT_BOOLEAN_TESTS + AUTOCOMMIT_TYPE_REJECTION_TESTS + AUTOCOMMIT_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(AUTOCOMMIT_TESTS)) +def test_abortTransaction_autocommit_error(collection, test): + """Test abortTransaction autocommit error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_cannot_commit_after_abort.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_cannot_commit_after_abort.py new file mode 100644 index 000000000..6369a8a49 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_cannot_commit_after_abort.py @@ -0,0 +1,54 @@ +"""Test that committing a transaction that was already aborted fails. + +Once a transaction is aborted, sending commitTransaction for that same +transaction (same session id and txnNumber) fails with NoSuchTransaction. The +transaction is driven with explicit session fields so the same txnNumber can be +referenced across the start, abort, and commit commands. A dedicated client is +used for this hand-managed session so it does not disturb the shared client's +server-session pool (which would conflict with other tests under parallel runs). +""" + +from __future__ import annotations + +import uuid + +import pytest +from bson import Binary, Int64 +from pymongo import MongoClient + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import NO_SUCH_TRANSACTION_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_cannot_commit_after_abort(collection, connection_string): + """Committing an already-aborted transaction fails with NoSuchTransaction.""" + lsid = {"id": Binary(uuid.uuid4().bytes, 4)} # session id (UUID) + txn_number = Int64(1) + probe_client = MongoClient(connection_string) + try: + probe_collection = probe_client[collection.database.name][collection.name] + execute_command( + probe_collection, + { + "insert": collection.name, + "documents": [{"_id": 1}], + "lsid": lsid, + "txnNumber": txn_number, + "startTransaction": True, + "autocommit": False, + }, + ) + execute_admin_command( + probe_collection, + {"abortTransaction": 1, "lsid": lsid, "txnNumber": txn_number, "autocommit": False}, + ) + result = execute_admin_command( + probe_collection, + {"commitTransaction": 1, "lsid": lsid, "txnNumber": txn_number, "autocommit": False}, + ) + finally: + probe_client.close() + assertFailureCode(result, NO_SUCH_TRANSACTION_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_comment.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_comment.py new file mode 100644 index 000000000..33bf4a3fb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_comment.py @@ -0,0 +1,145 @@ +"""Tests for abortTransaction comment parameter type acceptance in a real transaction. + +Validates that the comment parameter accepts any BSON type when +abortTransaction is issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [comment Type Acceptance]: comment accepts any BSON type. +COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "comment_string", + command={"abortTransaction": 1, "comment": "test comment"}, + msg="abortTransaction should accept comment:string", + ), + CommandTestCase( + "comment_string_empty", + command={"abortTransaction": 1, "comment": ""}, + msg="abortTransaction should accept comment:empty string", + ), + CommandTestCase( + "comment_int32", + command={"abortTransaction": 1, "comment": 42}, + msg="abortTransaction should accept comment:int32", + ), + CommandTestCase( + "comment_int64", + command={"abortTransaction": 1, "comment": Int64(42)}, + msg="abortTransaction should accept comment:Int64", + ), + CommandTestCase( + "comment_double", + command={"abortTransaction": 1, "comment": 3.14}, + msg="abortTransaction should accept comment:double", + ), + CommandTestCase( + "comment_decimal128", + command={"abortTransaction": 1, "comment": Decimal128("1.5")}, + msg="abortTransaction should accept comment:Decimal128", + ), + CommandTestCase( + "comment_bool_true", + command={"abortTransaction": 1, "comment": True}, + msg="abortTransaction should accept comment:true", + ), + CommandTestCase( + "comment_bool_false", + command={"abortTransaction": 1, "comment": False}, + msg="abortTransaction should accept comment:false", + ), + CommandTestCase( + "comment_null", + command={"abortTransaction": 1, "comment": None}, + msg="abortTransaction should accept comment:null", + ), + CommandTestCase( + "comment_object", + command={"abortTransaction": 1, "comment": {"key": "value"}}, + msg="abortTransaction should accept comment:object", + ), + CommandTestCase( + "comment_object_empty", + command={"abortTransaction": 1, "comment": {}}, + msg="abortTransaction should accept comment:empty object", + ), + CommandTestCase( + "comment_array", + command={"abortTransaction": 1, "comment": [1, 2, 3]}, + msg="abortTransaction should accept comment:array", + ), + CommandTestCase( + "comment_array_empty", + command={"abortTransaction": 1, "comment": []}, + msg="abortTransaction should accept comment:empty array", + ), + CommandTestCase( + "comment_objectid", + command={"abortTransaction": 1, "comment": ObjectId()}, + msg="abortTransaction should accept comment:ObjectId", + ), + CommandTestCase( + "comment_datetime", + command={ + "abortTransaction": 1, + "comment": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + msg="abortTransaction should accept comment:datetime", + ), + CommandTestCase( + "comment_binary", + command={"abortTransaction": 1, "comment": Binary(b"\x00")}, + msg="abortTransaction should accept comment:Binary", + ), + CommandTestCase( + "comment_regex", + command={"abortTransaction": 1, "comment": Regex(".*")}, + msg="abortTransaction should accept comment:Regex", + ), + CommandTestCase( + "comment_timestamp", + command={"abortTransaction": 1, "comment": Timestamp(0, 0)}, + msg="abortTransaction should accept comment:Timestamp", + ), + CommandTestCase( + "comment_minkey", + command={"abortTransaction": 1, "comment": MinKey()}, + msg="abortTransaction should accept comment:MinKey", + ), + CommandTestCase( + "comment_maxkey", + command={"abortTransaction": 1, "comment": MaxKey()}, + msg="abortTransaction should accept comment:MaxKey", + ), + CommandTestCase( + "comment_code", + command={"abortTransaction": 1, "comment": Code("function(){}")}, + msg="abortTransaction should accept comment:Code", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(COMMENT_TYPE_TESTS)) +def test_abortTransaction_comment(collection, test): + """Test abortTransaction comment parameter type acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core.py new file mode 100644 index 000000000..3bff3216f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core.py @@ -0,0 +1,64 @@ +"""Tests for abortTransaction command success behavior. + +Each test drives the full transaction lifecycle inline (start session, start +transaction, run an operation, abort) so that setup -> act -> assert reads top +to bottom. These cover the fundamental abort behaviors: an aborted write is +rolled back, pre-transaction data survives the abort, an empty transaction +aborts, and the response carries ok:1. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import ( + assertNotError, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_abortTransaction_rolls_back_insert(collection): + """An aborted insert leaves no trace after the transaction aborts.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1, "x": "inserted"}, session=session) + session.abort_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, []) + + +def test_pre_existing_data_survives_abort(collection): + """Documents inserted before the transaction survive an abort.""" + collection.insert_one({"_id": 1, "x": "seed"}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 2, "x": "txn"}, session=session) + session.abort_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, [{"_id": 1, "x": "seed"}]) + + +def test_abortTransaction_empty(collection): + """Aborting a transaction with no operations succeeds.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + session.abort_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertNotError(readback, msg="abortTransaction on an empty transaction should not error") + + +def test_abortTransaction_response_ok(collection): + """The abortTransaction command response reports ok:1 on success.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, {"abortTransaction": 1}, session=session) + assertSuccessPartial(result, {"ok": 1.0}) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core_error.py new file mode 100644 index 000000000..6abd9770c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_core_error.py @@ -0,0 +1,94 @@ +"""Tests for abortTransaction command core error cases. + +Validates fundamental command error behavior including parameter acceptance, +parameter interactions, and the admin database requirement. On a replica set, +commands with autocommit + txnNumber produce TransactionTooOld (251), and +txnNumber alone produces NotARetryableWriteCommand (50768). +""" + +from __future__ import annotations + +import pytest +from bson import Binary, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + TRANSACTION_TOO_OLD_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [Parameter Acceptance]: all valid parameters combined are syntactically accepted. +CORE_PARAMETER_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_valid_params_no_parse_error", + command={ + "abortTransaction": 1, + "autocommit": False, + "txnNumber": Int64(1), + "writeConcern": {"w": "majority", "j": True, "wtimeout": 10_000}, + "comment": "full abort", + }, + error_code=TRANSACTION_TOO_OLD_ERROR, + msg="All valid params combined should not produce a parsing error", + ), +] + +# Property [Parameter Interactions]: combinations of valid parameters behave correctly. +CORE_PARAMETER_INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "interaction_txn_number_only", + command={"abortTransaction": 1, "txnNumber": Int64(1)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="abortTransaction with txnNumber only should fail with NotARetryableWriteCommand", + ), + CommandTestCase( + "interaction_autocommit_txn_number", + command={"abortTransaction": 1, "autocommit": False, "txnNumber": Int64(1)}, + error_code=TRANSACTION_TOO_OLD_ERROR, + msg="abortTransaction with autocommit + txnNumber should fail with TransactionTooOld", + ), + CommandTestCase( + "lsid_recognized_field", + command={"abortTransaction": 1, "lsid": {"id": Binary(b"\x00" * 16, 4)}}, + error_code=COMMAND_FAILED_ERROR, + msg="lsid should be recognized (CommandFailed, not UnrecognizedField)", + ), +] + +CORE_ERROR_TESTS: list[CommandTestCase] = ( + CORE_PARAMETER_ACCEPTANCE_TESTS + CORE_PARAMETER_INTERACTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CORE_ERROR_TESTS)) +def test_abortTransaction_core_error(collection, test): + """Test abortTransaction core error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Property [Admin Database Requirement]: abortTransaction must run against the admin database. +ADMIN_DB_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "non_admin_database", + command={"abortTransaction": 1}, + error_code=UNAUTHORIZED_ERROR, + msg="abortTransaction on a non-admin database should fail with Unauthorized", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ADMIN_DB_TESTS)) +def test_abortTransaction_admin_db_required(collection, test): + """Test abortTransaction requires admin database.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_failed_op_blocks_txn.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_failed_op_blocks_txn.py new file mode 100644 index 000000000..057cad3c7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_failed_op_blocks_txn.py @@ -0,0 +1,37 @@ +"""Test that a failed operation blocks the rest of the transaction. + +After a write inside a transaction fails (here, a duplicate-key error), the +transaction is aborted by the server, so a later operation in the same +transaction fails with NoSuchTransaction. The transaction must be aborted and +restarted to recover (covered separately). +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import NO_SUCH_TRANSACTION_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_failed_op_blocks_rest_of_transaction(collection): + """A later op after a failed op in the same transaction fails with NoSuchTransaction.""" + collection.insert_one({"_id": 1}) # pre-existing doc so the txn insert collides + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + # Duplicate-key write (11000) fails and aborts the transaction. + execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1}]}, + session=session, + ) + later = execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 2}]}, + session=session, + ) + assertFailureCode(later, NO_SUCH_TRANSACTION_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_field_types.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_field_types.py new file mode 100644 index 000000000..5d8235dcc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_field_types.py @@ -0,0 +1,177 @@ +"""Tests for abortTransaction command field type acceptance in a real transaction. + +Validates that the abortTransaction command's primary field accepts all BSON +types when issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [Field Type Acceptance]: the command field accepts any BSON type. +FIELD_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "field_int32_positive", + command={"abortTransaction": 1}, + msg="abortTransaction should accept int32 positive value", + ), + CommandTestCase( + "field_int32_negative", + command={"abortTransaction": -1}, + msg="abortTransaction should accept int32 negative value", + ), + CommandTestCase( + "field_int32_zero", + command={"abortTransaction": 0}, + msg="abortTransaction should accept int32 zero value", + ), + CommandTestCase( + "field_int64", + command={"abortTransaction": Int64(1)}, + msg="abortTransaction should accept int64 value", + ), + CommandTestCase( + "field_int64_max", + command={"abortTransaction": Int64(9_223_372_036_854_775_807)}, + msg="abortTransaction should accept int64 max value", + ), + CommandTestCase( + "field_double", + command={"abortTransaction": 1.0}, + msg="abortTransaction should accept double value", + ), + CommandTestCase( + "field_double_negative", + command={"abortTransaction": -1.0}, + msg="abortTransaction should accept negative double value", + ), + CommandTestCase( + "field_double_zero", + command={"abortTransaction": 0.0}, + msg="abortTransaction should accept double zero value", + ), + CommandTestCase( + "field_decimal128", + command={"abortTransaction": Decimal128("1")}, + msg="abortTransaction should accept Decimal128 value", + ), + CommandTestCase( + "field_bool_true", + command={"abortTransaction": True}, + msg="abortTransaction should accept bool true value", + ), + CommandTestCase( + "field_bool_false", + command={"abortTransaction": False}, + msg="abortTransaction should accept bool false value", + ), + CommandTestCase( + "field_nan", + command={"abortTransaction": float("nan")}, + msg="abortTransaction should accept NaN value", + ), + CommandTestCase( + "field_infinity", + command={"abortTransaction": float("inf")}, + msg="abortTransaction should accept Infinity value", + ), + CommandTestCase( + "field_string", + command={"abortTransaction": "abortTransaction"}, + msg="abortTransaction should accept string value", + ), + CommandTestCase( + "field_string_empty", + command={"abortTransaction": ""}, + msg="abortTransaction should accept empty string value", + ), + CommandTestCase( + "field_null", + command={"abortTransaction": None}, + msg="abortTransaction should accept null value", + ), + CommandTestCase( + "field_object_empty", + command={"abortTransaction": {}}, + msg="abortTransaction should accept empty object value", + ), + CommandTestCase( + "field_object_nonempty", + command={"abortTransaction": {"key": "value"}}, + msg="abortTransaction should accept non-empty object value", + ), + CommandTestCase( + "field_array_empty", + command={"abortTransaction": []}, + msg="abortTransaction should accept empty array value", + ), + CommandTestCase( + "field_array_nonempty", + command={"abortTransaction": [1, 2]}, + msg="abortTransaction should accept non-empty array value", + ), + CommandTestCase( + "field_binary", + command={"abortTransaction": Binary(b"\x00")}, + msg="abortTransaction should accept Binary value", + ), + CommandTestCase( + "field_objectid", + command={"abortTransaction": ObjectId()}, + msg="abortTransaction should accept ObjectId value", + ), + CommandTestCase( + "field_datetime", + command={"abortTransaction": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="abortTransaction should accept datetime value", + ), + CommandTestCase( + "field_regex", + command={"abortTransaction": Regex(".*")}, + msg="abortTransaction should accept Regex value", + ), + CommandTestCase( + "field_timestamp", + command={"abortTransaction": Timestamp(0, 0)}, + msg="abortTransaction should accept Timestamp value", + ), + CommandTestCase( + "field_code", + command={"abortTransaction": Code("function(){}")}, + msg="abortTransaction should accept Code value", + ), + CommandTestCase( + "field_minkey", + command={"abortTransaction": MinKey()}, + msg="abortTransaction should accept MinKey value", + ), + CommandTestCase( + "field_maxkey", + command={"abortTransaction": MaxKey()}, + msg="abortTransaction should accept MaxKey value", + ), +] + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(FIELD_TYPE_TESTS)) +def test_abortTransaction_field_types(collection, test): + """Test abortTransaction command field type acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_recovers_session.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_recovers_session.py new file mode 100644 index 000000000..f1551c3bb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_recovers_session.py @@ -0,0 +1,35 @@ +"""Test that aborting recovers a session after a failed operation. + +After a write inside a transaction fails and the transaction is aborted, the +same session can start and commit a fresh transaction successfully. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_abort_recovers_session_for_new_transaction(collection): + """After a failed op and abort, the same session can run a new transaction.""" + collection.insert_one({"_id": 1}) # pre-existing doc so the first txn insert collides + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + # Duplicate-key write fails and aborts the transaction. + execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1}]}, + session=session, + ) + session.abort_transaction() + # The same session starts a fresh transaction and commits successfully. + session.start_transaction() + collection.insert_one({"_id": 2, "x": "recovered"}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {"_id": 2}}) + assertSuccess(readback, [{"_id": 2, "x": "recovered"}]) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_txn_number_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_txn_number_error.py new file mode 100644 index 000000000..e07f550d9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_txn_number_error.py @@ -0,0 +1,131 @@ +"""Tests for abortTransaction txnNumber parameter validation. + +Validates type acceptance for the txnNumber parameter. Per the MongoDB +documentation, txnNumber is typed as long (Int64). On a replica set, +Int64 values produce NotARetryableWriteCommand (50768) when there is no +matching transaction, while negative values produce InvalidOptions (72). +Non-Int64 numeric types and non-numeric types produce TypeMismatch. Null +is treated as omitted (falls through to NoSuchTransaction). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [txnNumber Int64 Acceptance]: Int64 values are accepted for txnNumber. +TXN_NUMBER_INT64_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_int64_positive", + command={"abortTransaction": 1, "txnNumber": Int64(1)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="abortTransaction should accept txnNumber:Int64(1) and fail", + ), + CommandTestCase( + "txn_number_int64_zero", + command={"abortTransaction": 1, "txnNumber": Int64(0)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="abortTransaction should accept txnNumber:Int64(0) and fail", + ), + CommandTestCase( + "txn_number_int64_max", + command={"abortTransaction": 1, "txnNumber": Int64(9_223_372_036_854_775_807)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="abortTransaction should accept txnNumber:Int64 max value", + ), + CommandTestCase( + "txn_number_int64_negative", + command={"abortTransaction": 1, "txnNumber": Int64(-1)}, + error_code=INVALID_OPTIONS_ERROR, + msg="abortTransaction should reject negative txnNumber with InvalidOptions", + ), +] + +# Property [txnNumber Type Strictness]: non-Int64 types are rejected with TypeMismatch. +TXN_NUMBER_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_int32", + command={"abortTransaction": 1, "txnNumber": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:int32 as wrong type", + ), + CommandTestCase( + "txn_number_double_whole", + command={"abortTransaction": 1, "txnNumber": 1.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:double (whole) as wrong type", + ), + CommandTestCase( + "txn_number_double_fractional", + command={"abortTransaction": 1, "txnNumber": 1.5}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:double (fractional) as wrong type", + ), + CommandTestCase( + "txn_number_decimal128", + command={"abortTransaction": 1, "txnNumber": Decimal128("1")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:Decimal128 as wrong type", + ), + CommandTestCase( + "txn_number_string", + command={"abortTransaction": 1, "txnNumber": "1"}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:string as wrong type", + ), + CommandTestCase( + "txn_number_bool", + command={"abortTransaction": 1, "txnNumber": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:bool as wrong type", + ), + CommandTestCase( + "txn_number_object", + command={"abortTransaction": 1, "txnNumber": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:{} (object) as wrong type", + ), + CommandTestCase( + "txn_number_array", + command={"abortTransaction": 1, "txnNumber": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject txnNumber:[] (array) as wrong type", + ), +] + +# Property [txnNumber Null Handling]: null txnNumber is treated as omitted. +TXN_NUMBER_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_null", + command={"abortTransaction": 1, "txnNumber": None}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should treat txnNumber:null as omitted", + ), +] + +TXN_NUMBER_TESTS: list[CommandTestCase] = ( + TXN_NUMBER_INT64_TESTS + TXN_NUMBER_TYPE_REJECTION_TESTS + TXN_NUMBER_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(TXN_NUMBER_TESTS)) +def test_abortTransaction_txn_number_error(collection, test): + """Test abortTransaction txnNumber error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_unrecognized_fields_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_unrecognized_fields_error.py new file mode 100644 index 000000000..55b52b26b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_unrecognized_fields_error.py @@ -0,0 +1,106 @@ +"""Tests for abortTransaction unrecognized field handling. + +Validates that the abortTransaction command rejects unknown fields. Covers +single unknown fields, multiple unknown fields, case-sensitive field names, +known fields from other commands, and dollar-prefixed fields. +""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import UNRECOGNIZED_COMMAND_FIELD_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [Unrecognized Field Rejection]: unknown fields are rejected. +UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_single_field", + command={"abortTransaction": 1, "unknownField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject single unknown field", + ), + CommandTestCase( + "unknown_multiple_fields", + command={"abortTransaction": 1, "foo": 1, "bar": 2}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject multiple unknown fields", + ), +] + +# Property [Case Sensitivity]: field names are case-sensitive and wrong-case variants are rejected. +CASE_SENSITIVITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "case_WriteConcern", + command={"abortTransaction": 1, "WriteConcern": {"w": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'WriteConcern' (capital W) as unrecognized", + ), + CommandTestCase( + "case_Autocommit", + command={"abortTransaction": 1, "Autocommit": False}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'Autocommit' (capital A) as unrecognized", + ), + CommandTestCase( + "case_TxnNumber", + command={"abortTransaction": 1, "TxnNumber": Int64(1)}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'TxnNumber' (capital T) as unrecognized", + ), + CommandTestCase( + "case_Comment", + command={"abortTransaction": 1, "Comment": "test"}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'Comment' (capital C) as unrecognized", + ), +] + +# Property [Foreign Field Rejection]: fields from other commands are rejected. +FOREIGN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "foreign_query", + command={"abortTransaction": 1, "query": {"x": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject 'query' field from other commands", + ), + CommandTestCase( + "dollar_prefixed", + command={"abortTransaction": 1, "$unknown": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject dollar-prefixed unknown field", + ), +] + +# Property [writeConcern Unknown Sub-Field]: unknown writeConcern sub-fields are rejected. +WRITECONCERN_UNKNOWN_SUBFIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wc_unknown_subfield", + command={"abortTransaction": 1, "writeConcern": {"w": 1, "unknownOption": True}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="abortTransaction should reject unknown writeConcern sub-field", + ), +] + +UNRECOGNIZED_TESTS: list[CommandTestCase] = ( + UNRECOGNIZED_FIELD_TESTS + + CASE_SENSITIVITY_TESTS + + FOREIGN_FIELD_TESTS + + WRITECONCERN_UNKNOWN_SUBFIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(UNRECOGNIZED_TESTS)) +def test_abortTransaction_unrecognized_fields_error(collection, test): + """Test abortTransaction unrecognized field error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern.py new file mode 100644 index 000000000..6b66e9024 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern.py @@ -0,0 +1,194 @@ +"""Tests for abortTransaction writeConcern parameter acceptance in a real transaction. + +Validates that accepted writeConcern variants (document types, w sub-field +values, j sub-field values, wtimeout sub-field values, and combinations) +succeed when abortTransaction is issued inside an active transaction on a +replica set. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [writeConcern Document Acceptance]: writeConcern accepts document values. +WRITECONCERN_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_empty_doc", + command={"abortTransaction": 1, "writeConcern": {}}, + msg="abortTransaction should accept empty writeConcern document", + ), + CommandTestCase( + "writeconcern_null", + command={"abortTransaction": 1, "writeConcern": None}, + msg="abortTransaction should accept writeConcern:null", + ), + CommandTestCase( + "wc_combined_w_j_wtimeout", + command={ + "abortTransaction": 1, + "writeConcern": {"w": "majority", "j": True, "wtimeout": 10_000}, + }, + msg="abortTransaction should accept combined w + j + wtimeout", + ), + CommandTestCase( + "wc_w0_j_true", + command={"abortTransaction": 1, "writeConcern": {"w": 0, "j": True}}, + msg="abortTransaction should accept conflicting w:0 with j:true", + ), + CommandTestCase( + "wc_fsync_true", + command={"abortTransaction": 1, "writeConcern": {"fsync": True}}, + msg="abortTransaction should accept legacy writeConcern.fsync:true", + ), +] + +# Property [w Accepted Values]: w accepts int and string "majority" values. +W_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_int32_one", + command={"abortTransaction": 1, "writeConcern": {"w": 1}}, + msg="abortTransaction should accept writeConcern.w:1", + ), + CommandTestCase( + "w_int32_zero", + command={"abortTransaction": 1, "writeConcern": {"w": 0}}, + msg="abortTransaction should accept writeConcern.w:0 (unacknowledged)", + ), + CommandTestCase( + "w_majority", + command={"abortTransaction": 1, "writeConcern": {"w": "majority"}}, + msg="abortTransaction should accept writeConcern.w:'majority'", + ), + CommandTestCase( + "w_int64", + command={"abortTransaction": 1, "writeConcern": {"w": Int64(1)}}, + msg="abortTransaction should accept writeConcern.w:Int64(1)", + ), + CommandTestCase( + "w_double_whole", + command={"abortTransaction": 1, "writeConcern": {"w": 1.0}}, + msg="abortTransaction should accept writeConcern.w:1.0", + ), + CommandTestCase( + "w_double_fractional", + command={"abortTransaction": 1, "writeConcern": {"w": 1.5}}, + msg="abortTransaction should accept writeConcern.w:1.5", + ), + CommandTestCase( + "w_decimal128", + command={"abortTransaction": 1, "writeConcern": {"w": Decimal128("1")}}, + msg="abortTransaction should accept writeConcern.w:Decimal128('1')", + ), +] + +# Property [j Accepted Values]: j accepts boolean and numeric types. +J_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "j_bool_true", + command={"abortTransaction": 1, "writeConcern": {"j": True}}, + msg="abortTransaction should accept writeConcern.j:true", + ), + CommandTestCase( + "j_bool_false", + command={"abortTransaction": 1, "writeConcern": {"j": False}}, + msg="abortTransaction should accept writeConcern.j:false", + ), + CommandTestCase( + "j_int32_one", + command={"abortTransaction": 1, "writeConcern": {"j": 1}}, + msg="abortTransaction should accept writeConcern.j:1 (coerced to true)", + ), + CommandTestCase( + "j_int32_zero", + command={"abortTransaction": 1, "writeConcern": {"j": 0}}, + msg="abortTransaction should accept writeConcern.j:0 (coerced to false)", + ), + CommandTestCase( + "j_null", + command={"abortTransaction": 1, "writeConcern": {"j": None}}, + msg="abortTransaction should accept writeConcern.j:null", + ), +] + +# Property [wtimeout Accepted Values]: wtimeout accepts numeric types broadly. +WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_int32_positive", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": 1000}}, + msg="abortTransaction should accept writeConcern.wtimeout:1000", + ), + CommandTestCase( + "wtimeout_int32_zero", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": 0}}, + msg="abortTransaction should accept writeConcern.wtimeout:0 (no timeout)", + ), + CommandTestCase( + "wtimeout_int64", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": Int64(1000)}}, + msg="abortTransaction should accept writeConcern.wtimeout:Int64(1000)", + ), + CommandTestCase( + "wtimeout_double_whole", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": 1000.0}}, + msg="abortTransaction should accept writeConcern.wtimeout:1000.0", + ), + CommandTestCase( + "wtimeout_negative", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": -1}}, + msg="abortTransaction should accept writeConcern.wtimeout:-1", + ), + CommandTestCase( + "wtimeout_string", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": "1000"}}, + msg="abortTransaction should accept writeConcern.wtimeout:'1000'", + ), + CommandTestCase( + "wtimeout_bool", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": True}}, + msg="abortTransaction should accept writeConcern.wtimeout:true", + ), + CommandTestCase( + "wtimeout_null", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": None}}, + msg="abortTransaction should accept writeConcern.wtimeout:null", + ), + CommandTestCase( + "wtimeout_object", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": {}}}, + msg="abortTransaction should accept writeConcern.wtimeout:{}", + ), + CommandTestCase( + "wtimeout_array", + command={"abortTransaction": 1, "writeConcern": {"wtimeout": []}}, + msg="abortTransaction should accept writeConcern.wtimeout:[]", + ), +] + +WRITECONCERN_ACCEPTANCE_ALL_TESTS: list[CommandTestCase] = ( + WRITECONCERN_ACCEPTANCE_TESTS + + W_ACCEPTANCE_TESTS + + J_ACCEPTANCE_TESTS + + WTIMEOUT_ACCEPTANCE_TESTS +) + + +@pytest.mark.admin +@pytest.mark.parametrize("test", pytest_params(WRITECONCERN_ACCEPTANCE_ALL_TESTS)) +def test_abortTransaction_writeconcern(collection, test): + """Test abortTransaction writeConcern parameter acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern_error.py new file mode 100644 index 000000000..7ba972464 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/abortTransaction/test_abortTransaction_writeconcern_error.py @@ -0,0 +1,239 @@ +"""Tests for abortTransaction writeConcern parameter error cases. + +Validates that invalid writeConcern types and sub-field values are rejected +with the appropriate error codes. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [writeConcern Type Rejection]: non-document types are rejected with TypeMismatch. +WRITECONCERN_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_string", + command={"abortTransaction": 1, "writeConcern": "majority"}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:string as wrong type", + ), + CommandTestCase( + "writeconcern_int32", + command={"abortTransaction": 1, "writeConcern": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:int32 as wrong type", + ), + CommandTestCase( + "writeconcern_int64", + command={"abortTransaction": 1, "writeConcern": Int64(1)}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Int64 as wrong type", + ), + CommandTestCase( + "writeconcern_double", + command={"abortTransaction": 1, "writeConcern": 1.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:double as wrong type", + ), + CommandTestCase( + "writeconcern_decimal128", + command={"abortTransaction": 1, "writeConcern": Decimal128("1")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Decimal128 as wrong type", + ), + CommandTestCase( + "writeconcern_bool_true", + command={"abortTransaction": 1, "writeConcern": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:true as wrong type", + ), + CommandTestCase( + "writeconcern_bool_false", + command={"abortTransaction": 1, "writeConcern": False}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:false as wrong type", + ), + CommandTestCase( + "writeconcern_array_empty", + command={"abortTransaction": 1, "writeConcern": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:[] as wrong type", + ), + CommandTestCase( + "writeconcern_array_nonempty", + command={"abortTransaction": 1, "writeConcern": [{"w": 1}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:[{w:1}] as wrong type", + ), + CommandTestCase( + "writeconcern_binary", + command={"abortTransaction": 1, "writeConcern": Binary(b"\x00")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Binary as wrong type", + ), + CommandTestCase( + "writeconcern_objectid", + command={"abortTransaction": 1, "writeConcern": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:ObjectId as wrong type", + ), + CommandTestCase( + "writeconcern_datetime", + command={"abortTransaction": 1, "writeConcern": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:datetime as wrong type", + ), + CommandTestCase( + "writeconcern_regex", + command={"abortTransaction": 1, "writeConcern": Regex(".*")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Regex as wrong type", + ), + CommandTestCase( + "writeconcern_timestamp", + command={"abortTransaction": 1, "writeConcern": Timestamp(0, 0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Timestamp as wrong type", + ), + CommandTestCase( + "writeconcern_code", + command={"abortTransaction": 1, "writeConcern": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:Code as wrong type", + ), + CommandTestCase( + "writeconcern_minkey", + command={"abortTransaction": 1, "writeConcern": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:MinKey as wrong type", + ), + CommandTestCase( + "writeconcern_maxkey", + command={"abortTransaction": 1, "writeConcern": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern:MaxKey as wrong type", + ), +] + +# Property [w Invalid Values]: invalid w values are rejected with CommandFailed or FailedToParse. +W_INVALID_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_custom_tag", + command={"abortTransaction": 1, "writeConcern": {"w": "myTag"}}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should reject writeConcern.w:'myTag' with CommandFailed", + ), + CommandTestCase( + "w_empty_string", + command={"abortTransaction": 1, "writeConcern": {"w": ""}}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should reject writeConcern.w:'' with CommandFailed", + ), + CommandTestCase( + "w_null", + command={"abortTransaction": 1, "writeConcern": {"w": None}}, + error_code=COMMAND_FAILED_ERROR, + msg="abortTransaction should reject writeConcern.w:null with CommandFailed", + ), + CommandTestCase( + "w_negative_int", + command={"abortTransaction": 1, "writeConcern": {"w": -1}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:-1 with FailedToParse", + ), + CommandTestCase( + "w_int32_max", + command={"abortTransaction": 1, "writeConcern": {"w": 2_147_483_647}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:INT32_MAX with FailedToParse", + ), + CommandTestCase( + "w_bool_false", + command={"abortTransaction": 1, "writeConcern": {"w": False}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:false with FailedToParse", + ), + CommandTestCase( + "w_bool_true", + command={"abortTransaction": 1, "writeConcern": {"w": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:true with FailedToParse", + ), + CommandTestCase( + "w_object", + command={"abortTransaction": 1, "writeConcern": {"w": {}}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:{} with FailedToParse", + ), + CommandTestCase( + "w_array", + command={"abortTransaction": 1, "writeConcern": {"w": []}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.w:[] with FailedToParse", + ), +] + +# Property [j Type Rejection]: non-boolean non-numeric types are rejected with TypeMismatch. +J_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "j_string", + command={"abortTransaction": 1, "writeConcern": {"j": "true"}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern.j:'true' as wrong type", + ), + CommandTestCase( + "j_object", + command={"abortTransaction": 1, "writeConcern": {"j": {}}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern.j:{} as wrong type", + ), + CommandTestCase( + "j_array", + command={"abortTransaction": 1, "writeConcern": {"j": []}}, + error_code=TYPE_MISMATCH_ERROR, + msg="abortTransaction should reject writeConcern.j:[] as wrong type", + ), +] + +# Property [wtimeout Overflow]: Int64 max value overflows and produces FailedToParse. +WTIMEOUT_OVERFLOW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_int64_max", + command={ + "abortTransaction": 1, + "writeConcern": {"wtimeout": Int64(9_223_372_036_854_775_807)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="abortTransaction should reject writeConcern.wtimeout:Int64 max with FailedToParse", + ), +] + +WRITECONCERN_ERROR_TESTS: list[CommandTestCase] = ( + WRITECONCERN_TYPE_REJECTION_TESTS + + W_INVALID_VALUE_TESTS + + J_TYPE_REJECTION_TESTS + + WTIMEOUT_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(WRITECONCERN_ERROR_TESTS)) +def test_abortTransaction_writeconcern_error(collection, test): + """Test abortTransaction writeConcern parameter error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 78a86b0c1..fff507521 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -40,6 +40,7 @@ COMMAND_NOT_SUPPORTED_ERROR = 115 DOCUMENT_VALIDATION_FAILURE_ERROR = 121 NOT_A_REPLICA_SET_ERROR = 123 +COMMAND_FAILED_ERROR = 125 CAPPED_POSITION_LOST_ERROR = 136 INCOMPATIBLE_COLLATION_VERSION_ERROR = 161 VIEW_DEPTH_LIMIT_ERROR = 165 @@ -53,8 +54,10 @@ INVALID_INDEX_SPEC_OPTION_ERROR = 197 INVALID_UUID_ERROR = 207 QUERY_FEATURE_NOT_ALLOWED = 224 +TRANSACTION_TOO_OLD_ERROR = 225 MAX_NESTED_SUB_PIPELINE_ERROR = 232 CONVERSION_FAILURE_ERROR = 241 +NO_SUCH_TRANSACTION_ERROR = 251 INVALID_RESUME_TOKEN_ERROR = 260 OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR = 263 CHANGE_STREAM_FATAL_ERROR = 280 @@ -368,6 +371,7 @@ TO_TYPE_ARITY_ERROR = 50723 CURSOR_SESSION_MISMATCH_ERROR = 50738 SUBSTR_NEGATIVE_START_ERROR = 50752 +NOT_A_RETRYABLE_WRITE_COMMAND_ERROR = 50768 KEYSTRING_UNKNOWN_TYPE_ERROR = 50811 CLUSTERED_NAN_DUPLICATE_ERROR = 50819 CLUSTERED_INFINITY_DUPLICATE_ERROR = 50826 From 15ac10c89b22aaa44ccfc4c7cc618dbcf898a5c9 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:07:45 -0700 Subject: [PATCH 42/51] commit Transaction tests (#560) Signed-off-by: Alina (Xi) Li --- .../commands/commitTransaction/__init__.py | 0 .../test_commitTransaction.py | 67 +++++ .../test_commitTransaction_atomicity.py | 28 ++ ...test_commitTransaction_autocommit_error.py | 116 ++++++++ .../test_commitTransaction_comment.py | 144 ++++++++++ .../test_commitTransaction_core_error.py | 110 ++++++++ ...commitTransaction_disallowed_operations.py | 245 +++++++++++++++++ .../test_commitTransaction_field_types.py | 176 ++++++++++++ .../test_commitTransaction_op_writeconcern.py | 29 ++ ..._commitTransaction_supported_operations.py | 217 +++++++++++++++ ...test_commitTransaction_txn_number_error.py | 131 +++++++++ ...itTransaction_unrecognized_fields_error.py | 106 ++++++++ .../test_commitTransaction_visibility.py | 40 +++ .../test_commitTransaction_write_conflict.py | 34 +++ .../test_commitTransaction_writeconcern.py | 192 +++++++++++++ ...st_commitTransaction_writeconcern_error.py | 256 ++++++++++++++++++ .../test_smoke_commitTransaction.py | 2 +- documentdb_tests/framework/error_codes.py | 1 + 18 files changed, 1893 insertions(+), 1 deletion(-) create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_atomicity.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_autocommit_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_comment.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_core_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_disallowed_operations.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_field_types.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_op_writeconcern.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_supported_operations.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_txn_number_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_unrecognized_fields_error.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_visibility.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_write_conflict.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern.py create mode 100644 documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern_error.py diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/__init__.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction.py new file mode 100644 index 000000000..34af5a44d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction.py @@ -0,0 +1,67 @@ +"""Tests for commitTransaction command success behavior. + +Each test drives the full transaction lifecycle inline (start session, start +transaction, run an operation, commit) so that setup -> act -> assert reads +top to bottom. These cover the fundamental commit behaviors: a committed write +is durable, an empty transaction commits, the response carries ok:1, and a +commit is retryable. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import ( + assertNotError, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_commitTransaction_persists_insert(collection): + """A committed insert is visible outside the transaction after commit.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1, "x": "inserted"}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, [{"_id": 1, "x": "inserted"}]) + + +def test_commitTransaction_empty(collection): + """Committing a transaction with no operations succeeds.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertNotError(readback, msg="commitTransaction on an empty transaction should not error") + + +def test_commitTransaction_response_ok(collection): + """The commitTransaction command response reports ok:1 on success.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, {"commitTransaction": 1}, session=session) + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_commitTransaction_is_retryable(collection): + """Re-sending commitTransaction after a successful commit returns ok:1. + + The committed state is retained for retryability, so the retry is a no-op + that succeeds rather than an error. + """ + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + execute_admin_command(collection, {"commitTransaction": 1}, session=session) + retry = execute_admin_command(collection, {"commitTransaction": 1}, session=session) + assertSuccessPartial(retry, {"ok": 1.0}) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_atomicity.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_atomicity.py new file mode 100644 index 000000000..628e98aee --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_atomicity.py @@ -0,0 +1,28 @@ +"""Tests for cross-collection atomicity of a committed transaction. + +Writes to more than one collection in a single transaction are committed +atomically: after commit, the writes to the second collection are durable, +demonstrating the commit spans every collection touched in the transaction. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_commit_persists_writes_across_collections(collection): + """Committing persists writes made to a second collection in the same txn.""" + client = collection.database.client + other = collection.database[f"{collection.name}_other"] + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + other.insert_one({"_id": 2}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": other.name, "filter": {}}) + assertSuccess(readback, [{"_id": 2}]) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_autocommit_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_autocommit_error.py new file mode 100644 index 000000000..47246e503 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_autocommit_error.py @@ -0,0 +1,116 @@ +"""Tests for commitTransaction autocommit parameter validation. + +Validates type and value acceptance for the autocommit parameter. Per the +MongoDB documentation, autocommit must be literal boolean false. Boolean true +produces InvalidOptions, non-boolean types produce TypeMismatch, and null is +treated as omitted (falls through to the no-transaction error). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [autocommit Boolean Values]: autocommit accepts only boolean values. +AUTOCOMMIT_BOOLEAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_bool_false", + command={"commitTransaction": 1, "autocommit": False}, + error_code=INVALID_OPTIONS_ERROR, + msg="commitTransaction should reject autocommit:false outside txn with InvalidOptions", + ), + CommandTestCase( + "autocommit_bool_true", + command={"commitTransaction": 1, "autocommit": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="commitTransaction should reject autocommit:true with InvalidOptions", + ), +] + +# Property [autocommit Type Strictness]: non-boolean types are rejected with TypeMismatch. +AUTOCOMMIT_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_int32_zero", + command={"commitTransaction": 1, "autocommit": 0}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:0 (int32) as wrong type", + ), + CommandTestCase( + "autocommit_int32_one", + command={"commitTransaction": 1, "autocommit": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:1 (int32) as wrong type", + ), + CommandTestCase( + "autocommit_int64_zero", + command={"commitTransaction": 1, "autocommit": Int64(0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:Int64(0) as wrong type", + ), + CommandTestCase( + "autocommit_double_zero", + command={"commitTransaction": 1, "autocommit": 0.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:0.0 as wrong type", + ), + CommandTestCase( + "autocommit_decimal128_zero", + command={"commitTransaction": 1, "autocommit": Decimal128("0")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:Decimal128('0') as wrong type", + ), + CommandTestCase( + "autocommit_string", + command={"commitTransaction": 1, "autocommit": "false"}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:'false' (string) as wrong type", + ), + CommandTestCase( + "autocommit_object", + command={"commitTransaction": 1, "autocommit": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:{} (object) as wrong type", + ), + CommandTestCase( + "autocommit_array", + command={"commitTransaction": 1, "autocommit": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject autocommit:[] (array) as wrong type", + ), +] + +# Property [autocommit Null Handling]: null autocommit is treated as omitted. +AUTOCOMMIT_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "autocommit_null", + command={"commitTransaction": 1, "autocommit": None}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should treat autocommit:null as omitted", + ), +] + +AUTOCOMMIT_TESTS: list[CommandTestCase] = ( + AUTOCOMMIT_BOOLEAN_TESTS + AUTOCOMMIT_TYPE_REJECTION_TESTS + AUTOCOMMIT_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(AUTOCOMMIT_TESTS)) +def test_commitTransaction_autocommit_error(collection, test): + """Test commitTransaction autocommit parameter validation.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_comment.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_comment.py new file mode 100644 index 000000000..87f49d7ed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_comment.py @@ -0,0 +1,144 @@ +"""Tests for commitTransaction comment parameter type acceptance in a real transaction. + +Validates that the comment parameter accepts any BSON type when +commitTransaction is issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [comment Type Acceptance]: comment accepts any BSON type. +COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "comment_string", + command={"commitTransaction": 1, "comment": "test comment"}, + msg="commitTransaction should accept comment:string", + ), + CommandTestCase( + "comment_string_empty", + command={"commitTransaction": 1, "comment": ""}, + msg="commitTransaction should accept comment:empty string", + ), + CommandTestCase( + "comment_int32", + command={"commitTransaction": 1, "comment": 42}, + msg="commitTransaction should accept comment:int32", + ), + CommandTestCase( + "comment_int64", + command={"commitTransaction": 1, "comment": Int64(42)}, + msg="commitTransaction should accept comment:Int64", + ), + CommandTestCase( + "comment_double", + command={"commitTransaction": 1, "comment": 3.14}, + msg="commitTransaction should accept comment:double", + ), + CommandTestCase( + "comment_decimal128", + command={"commitTransaction": 1, "comment": Decimal128("1.5")}, + msg="commitTransaction should accept comment:Decimal128", + ), + CommandTestCase( + "comment_bool_true", + command={"commitTransaction": 1, "comment": True}, + msg="commitTransaction should accept comment:true", + ), + CommandTestCase( + "comment_bool_false", + command={"commitTransaction": 1, "comment": False}, + msg="commitTransaction should accept comment:false", + ), + CommandTestCase( + "comment_null", + command={"commitTransaction": 1, "comment": None}, + msg="commitTransaction should accept comment:null", + ), + CommandTestCase( + "comment_object", + command={"commitTransaction": 1, "comment": {"key": "value"}}, + msg="commitTransaction should accept comment:object", + ), + CommandTestCase( + "comment_object_empty", + command={"commitTransaction": 1, "comment": {}}, + msg="commitTransaction should accept comment:empty object", + ), + CommandTestCase( + "comment_array", + command={"commitTransaction": 1, "comment": [1, 2, 3]}, + msg="commitTransaction should accept comment:array", + ), + CommandTestCase( + "comment_array_empty", + command={"commitTransaction": 1, "comment": []}, + msg="commitTransaction should accept comment:empty array", + ), + CommandTestCase( + "comment_objectid", + command={"commitTransaction": 1, "comment": ObjectId()}, + msg="commitTransaction should accept comment:ObjectId", + ), + CommandTestCase( + "comment_datetime", + command={ + "commitTransaction": 1, + "comment": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + msg="commitTransaction should accept comment:datetime", + ), + CommandTestCase( + "comment_binary", + command={"commitTransaction": 1, "comment": Binary(b"\x00")}, + msg="commitTransaction should accept comment:Binary", + ), + CommandTestCase( + "comment_regex", + command={"commitTransaction": 1, "comment": Regex(".*")}, + msg="commitTransaction should accept comment:Regex", + ), + CommandTestCase( + "comment_timestamp", + command={"commitTransaction": 1, "comment": Timestamp(0, 0)}, + msg="commitTransaction should accept comment:Timestamp", + ), + CommandTestCase( + "comment_minkey", + command={"commitTransaction": 1, "comment": MinKey()}, + msg="commitTransaction should accept comment:MinKey", + ), + CommandTestCase( + "comment_maxkey", + command={"commitTransaction": 1, "comment": MaxKey()}, + msg="commitTransaction should accept comment:MaxKey", + ), + CommandTestCase( + "comment_code", + command={"commitTransaction": 1, "comment": Code("function(){}")}, + msg="commitTransaction should accept comment:Code", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COMMENT_TYPE_TESTS)) +def test_commitTransaction_comment(collection, test): + """Test commitTransaction comment parameter type acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_core_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_core_error.py new file mode 100644 index 000000000..f18a720b8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_core_error.py @@ -0,0 +1,110 @@ +"""Tests for commitTransaction command core behavior. + +Validates fundamental command behavior including the no-transaction error, +admin database requirement, and parameter interactions. +""" + +from __future__ import annotations + +import pytest +from bson import Binary, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + TRANSACTION_TOO_OLD_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [No-Transaction Error]: commitTransaction outside a transaction fails. +CORE_NO_TRANSACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_transaction_basic", + command={"commitTransaction": 1}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction outside a transaction should fail", + ), +] + +# Property [Parameter Acceptance]: all valid parameters combined are syntactically accepted. +CORE_PARAMETER_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_valid_params_no_parse_error", + command={ + "commitTransaction": 1, + "autocommit": False, + "txnNumber": Int64(1), + "writeConcern": {"w": "majority", "j": True, "wtimeout": 10_000}, + "comment": "full commit", + }, + error_code=TRANSACTION_TOO_OLD_ERROR, + msg="commitTransaction with all valid params should not produce a parsing error", + ), +] + +# Property [Parameter Interactions]: combinations of valid parameters behave correctly. +CORE_PARAMETER_INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "interaction_autocommit_only", + command={"commitTransaction": 1, "autocommit": False}, + error_code=INVALID_OPTIONS_ERROR, + msg="commitTransaction with autocommit:false only should fail with InvalidOptions", + ), + CommandTestCase( + "interaction_txn_number_only", + command={"commitTransaction": 1, "txnNumber": Int64(1)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="commitTransaction with txnNumber only should fail with NotARetryableWriteCommand", + ), + CommandTestCase( + "interaction_autocommit_txn_number", + command={"commitTransaction": 1, "autocommit": False, "txnNumber": Int64(1)}, + error_code=TRANSACTION_TOO_OLD_ERROR, + msg="commitTransaction with autocommit + txnNumber should fail with TransactionTooOld", + ), + CommandTestCase( + "interaction_lsid", + command={"commitTransaction": 1, "lsid": {"id": Binary(b"\x00" * 16, 4)}}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction with explicit lsid should accept the field", + ), +] + +CORE_TESTS: list[CommandTestCase] = ( + CORE_NO_TRANSACTION_TESTS + CORE_PARAMETER_ACCEPTANCE_TESTS + CORE_PARAMETER_INTERACTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(CORE_TESTS)) +def test_commitTransaction_core_error(collection, test): + """Test commitTransaction core behavior.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Property [Admin Database Requirement]: commitTransaction must run against the admin database. +ADMIN_DB_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "non_admin_database", + command={"commitTransaction": 1}, + error_code=UNAUTHORIZED_ERROR, + msg="commitTransaction on a non-admin database should fail with Unauthorized", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ADMIN_DB_TESTS)) +def test_commitTransaction_admin_db_required_error(collection, test): + """Test commitTransaction requires admin database.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_disallowed_operations.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_disallowed_operations.py new file mode 100644 index 000000000..2492910d3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_disallowed_operations.py @@ -0,0 +1,245 @@ +"""Tests for operations that are not supported inside a transaction. + +Which operation types may run inside a transaction is its own concern (kept out +of the behavioral files). A range of commands and aggregation stages are +rejected with OperationNotSupportedInTransaction when issued inside an active +transaction, as are writes to a capped collection or to a system database. Each +test issues the operation in the transaction and asserts the rejection; the +session is closed by its ``with`` block. +""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Commands rejected inside a transaction with OperationNotSupportedInTransaction. +DISALLOWED_COMMANDS: list[CommandTestCase] = [ + CommandTestCase( + "count", + command=lambda ctx: {"count": ctx.collection}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="count command is not supported inside a transaction", + ), + CommandTestCase( + "listCollections", + command={"listCollections": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="listCollections is not supported inside a transaction", + ), + CommandTestCase( + "listIndexes", + command=lambda ctx: {"listIndexes": ctx.collection}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="listIndexes is not supported inside a transaction", + ), + CommandTestCase( + "explain", + command=lambda ctx: {"explain": {"find": ctx.collection}}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="explain is not supported inside a transaction", + ), + CommandTestCase( + "createUser", + command={"createUser": "commit_txn_user", "pwd": "commit_txn_pwd", "roles": []}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="createUser is not supported inside a transaction", + ), + CommandTestCase( + "getParameter", + command={"getParameter": 1, "logLevel": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="getParameter is not supported inside a transaction", + ), + CommandTestCase( + "setParameter", + command={"setParameter": 1, "logLevel": 0}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="setParameter is not supported inside a transaction", + ), + CommandTestCase( + "killCursors", + command=lambda ctx: {"killCursors": ctx.collection, "cursors": [Int64(0)]}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="killCursors is not supported as the first operation in a transaction", + ), + CommandTestCase( + "hello", + command={"hello": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="hello is not supported as the first operation in a transaction", + ), + CommandTestCase( + "buildInfo", + command={"buildInfo": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="buildInfo is not supported as the first operation in a transaction", + ), + CommandTestCase( + "connectionStatus", + command={"connectionStatus": 1}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="connectionStatus is not supported as the first operation in a transaction", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DISALLOWED_COMMANDS)) +def test_command_disallowed_in_transaction(collection, test): + """Commands not supported inside a transaction are rejected.""" + collection.insert_one({"_id": 1}) + ctx = CommandContext.from_collection(collection) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command(collection, test.build_command(ctx), session=session) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Aggregation stages rejected inside a transaction (run against the fixture +# collection via the aggregate command). +DISALLOWED_AGGREGATION_STAGES: list[CommandTestCase] = [ + CommandTestCase( + "out", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$out": f"{ctx.collection}_out"}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$out is not supported inside a transaction", + ), + CommandTestCase( + "merge", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$merge": {"into": f"{ctx.collection}_merge"}}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$merge is not supported inside a transaction", + ), + CommandTestCase( + "unionWith", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$unionWith": ctx.collection}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$unionWith is not supported inside a transaction", + ), + CommandTestCase( + "collStats", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$collStats": {"count": {}}}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$collStats is not supported inside a transaction", + ), + CommandTestCase( + "indexStats", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$indexStats": {}}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$indexStats is not supported inside a transaction", + ), + CommandTestCase( + "planCacheStats", + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$planCacheStats": {}}], + "cursor": {}, + }, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$planCacheStats is not supported inside a transaction", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DISALLOWED_AGGREGATION_STAGES)) +def test_aggregation_stage_disallowed_in_transaction(collection, test): + """Aggregation stages not supported inside a transaction are rejected.""" + collection.insert_one({"_id": 1}) + ctx = CommandContext.from_collection(collection) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command(collection, test.build_command(ctx), session=session) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# Admin-level (database) aggregation stages rejected inside a transaction. +DISALLOWED_ADMIN_STAGES: list[CommandTestCase] = [ + CommandTestCase( + "currentOp", + command={"aggregate": 1, "pipeline": [{"$currentOp": {}}], "cursor": {}}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$currentOp is not supported inside a transaction", + ), + CommandTestCase( + "listLocalSessions", + command={"aggregate": 1, "pipeline": [{"$listLocalSessions": {}}], "cursor": {}}, + error_code=OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR, + msg="$listLocalSessions is not supported inside a transaction", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DISALLOWED_ADMIN_STAGES)) +def test_admin_aggregation_stage_disallowed_in_transaction(collection, test): + """Admin-level aggregation stages not supported inside a transaction are rejected.""" + collection.insert_one({"_id": 1}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_admin_command(collection, test.command, session=session) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_write_to_capped_collection_disallowed_in_transaction(collection): + """Writing to a capped collection inside a transaction is not supported.""" + capped = collection.database.create_collection( + f"{collection.name}_capped", capped=True, size=4096 + ) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + {"insert": capped.name, "documents": [{"_id": 1}]}, + session=session, + ) + assertFailureCode(result, OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR) + + +@pytest.mark.parametrize("system_db_name", ["config", "local"]) +def test_write_to_system_database_disallowed_in_transaction(collection, system_db_name): + """Writing to a system database inside a transaction is not supported.""" + client = collection.database.client + system_collection = client[system_db_name][f"{collection.name}_probe"] + with client.start_session() as session: + session.start_transaction() + result = execute_command( + system_collection, + {"insert": system_collection.name, "documents": [{"_id": 1}]}, + session=session, + ) + assertFailureCode(result, OPERATION_NOT_SUPPORTED_IN_TRANSACTION_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_field_types.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_field_types.py new file mode 100644 index 000000000..624c5284e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_field_types.py @@ -0,0 +1,176 @@ +"""Tests for commitTransaction command field type acceptance in a real transaction. + +Validates that the commitTransaction command's primary field accepts all BSON +types when issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [Field Type Acceptance]: the command field accepts any BSON type. +FIELD_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "field_int32_positive", + command={"commitTransaction": 1}, + msg="commitTransaction should accept int32 positive value", + ), + CommandTestCase( + "field_int32_negative", + command={"commitTransaction": -1}, + msg="commitTransaction should accept int32 negative value", + ), + CommandTestCase( + "field_int32_zero", + command={"commitTransaction": 0}, + msg="commitTransaction should accept int32 zero value", + ), + CommandTestCase( + "field_int64", + command={"commitTransaction": Int64(1)}, + msg="commitTransaction should accept int64 value", + ), + CommandTestCase( + "field_int64_max", + command={"commitTransaction": Int64(9_223_372_036_854_775_807)}, + msg="commitTransaction should accept int64 max value", + ), + CommandTestCase( + "field_double", + command={"commitTransaction": 1.0}, + msg="commitTransaction should accept double value", + ), + CommandTestCase( + "field_double_negative", + command={"commitTransaction": -1.0}, + msg="commitTransaction should accept negative double value", + ), + CommandTestCase( + "field_double_zero", + command={"commitTransaction": 0.0}, + msg="commitTransaction should accept double zero value", + ), + CommandTestCase( + "field_decimal128", + command={"commitTransaction": Decimal128("1")}, + msg="commitTransaction should accept Decimal128 value", + ), + CommandTestCase( + "field_bool_true", + command={"commitTransaction": True}, + msg="commitTransaction should accept bool true value", + ), + CommandTestCase( + "field_bool_false", + command={"commitTransaction": False}, + msg="commitTransaction should accept bool false value", + ), + CommandTestCase( + "field_nan", + command={"commitTransaction": float("nan")}, + msg="commitTransaction should accept NaN value", + ), + CommandTestCase( + "field_infinity", + command={"commitTransaction": float("inf")}, + msg="commitTransaction should accept Infinity value", + ), + CommandTestCase( + "field_string", + command={"commitTransaction": "commitTransaction"}, + msg="commitTransaction should accept string value", + ), + CommandTestCase( + "field_string_empty", + command={"commitTransaction": ""}, + msg="commitTransaction should accept empty string value", + ), + CommandTestCase( + "field_null", + command={"commitTransaction": None}, + msg="commitTransaction should accept null value", + ), + CommandTestCase( + "field_object_empty", + command={"commitTransaction": {}}, + msg="commitTransaction should accept empty object value", + ), + CommandTestCase( + "field_object_nonempty", + command={"commitTransaction": {"key": "value"}}, + msg="commitTransaction should accept non-empty object value", + ), + CommandTestCase( + "field_array_empty", + command={"commitTransaction": []}, + msg="commitTransaction should accept empty array value", + ), + CommandTestCase( + "field_array_nonempty", + command={"commitTransaction": [1, 2]}, + msg="commitTransaction should accept non-empty array value", + ), + CommandTestCase( + "field_binary", + command={"commitTransaction": Binary(b"\x00")}, + msg="commitTransaction should accept Binary value", + ), + CommandTestCase( + "field_objectid", + command={"commitTransaction": ObjectId()}, + msg="commitTransaction should accept ObjectId value", + ), + CommandTestCase( + "field_datetime", + command={"commitTransaction": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="commitTransaction should accept datetime value", + ), + CommandTestCase( + "field_regex", + command={"commitTransaction": Regex(".*")}, + msg="commitTransaction should accept Regex value", + ), + CommandTestCase( + "field_timestamp", + command={"commitTransaction": Timestamp(0, 0)}, + msg="commitTransaction should accept Timestamp value", + ), + CommandTestCase( + "field_code", + command={"commitTransaction": Code("function(){}")}, + msg="commitTransaction should accept Code value", + ), + CommandTestCase( + "field_minkey", + command={"commitTransaction": MinKey()}, + msg="commitTransaction should accept MinKey value", + ), + CommandTestCase( + "field_maxkey", + command={"commitTransaction": MaxKey()}, + msg="commitTransaction should accept MaxKey value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(FIELD_TYPE_TESTS)) +def test_commitTransaction_field_types(collection, test): + """Test commitTransaction command field type acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_op_writeconcern.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_op_writeconcern.py new file mode 100644 index 000000000..75740fff0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_op_writeconcern.py @@ -0,0 +1,29 @@ +"""Test that an explicit writeConcern on an operation inside a transaction is rejected. + +Per the MongoDB docs, setting a write concern on the individual write operations +inside a transaction returns an error; the write concern is set on the +transaction as a whole (on commit) instead. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import INVALID_OPTIONS_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_op_level_write_concern_rejected_in_transaction(collection): + """An explicit writeConcern on a write operation inside a transaction is rejected.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1}], "writeConcern": {"w": 1}}, + session=session, + ) + assertFailureCode(result, INVALID_OPTIONS_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_supported_operations.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_supported_operations.py new file mode 100644 index 000000000..60e72f74c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_supported_operations.py @@ -0,0 +1,217 @@ +"""Tests for operations that are supported inside a transaction. + +Reads (find), aggregation (including ``$count``, the supported counterpart to +the disallowed ``count`` command), writes (update, delete), and a range of other +CRUD/administration commands and query operators are accepted inside a +transaction. Each test runs the operation in the transaction and commits; +acceptance is asserted via the ``ok: 1`` command response (contrast the +disallowed-operations file, where the same operations would be rejected). +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_find_runs_in_transaction(collection): + """A find issued inside a transaction returns the collection's documents.""" + collection.insert_one({"_id": 1}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, {"find": collection.name, "filter": {}}, session=session + ) + session.commit_transaction() + assertSuccess(result, [{"_id": 1}]) + + +def test_aggregate_count_runs_in_transaction(collection): + """An aggregate with $count runs inside a transaction and returns the count.""" + collection.insert_many([{"_id": 1}, {"_id": 2}]) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": [{"$count": "n"}], "cursor": {}}, + session=session, + ) + session.commit_transaction() + assertSuccess(result, [{"n": 2}]) + + +def test_update_runs_in_transaction(collection): + """An update issued inside a transaction is durable after commit.""" + collection.insert_one({"_id": 1, "x": "before"}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.update_one({"_id": 1}, {"$set": {"x": "after"}}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, [{"_id": 1, "x": "after"}]) + + +def test_delete_runs_in_transaction(collection): + """A delete issued inside a transaction is durable after commit.""" + collection.insert_one({"_id": 1}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.delete_one({"_id": 1}, session=session) + session.commit_transaction() + readback = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess(readback, []) + + +def test_distinct_runs_in_transaction(collection): + """The distinct command is accepted inside a transaction.""" + collection.insert_many([{"_id": 1, "x": 1}, {"_id": 2, "x": 2}]) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, {"distinct": collection.name, "key": "x"}, session=session + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_findAndModify_runs_in_transaction(collection): + """The findAndModify command is accepted inside a transaction.""" + collection.insert_one({"_id": 1, "x": "before"}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"x": "after"}}, + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_bulkWrite_runs_in_transaction(collection): + """The bulkWrite command is accepted inside a transaction.""" + client = collection.database.client + namespace = f"{collection.database.name}.{collection.name}" + with client.start_session() as session: + session.start_transaction() + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": namespace}], + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_create_collection_runs_in_transaction(collection): + """Creating a collection inside a transaction is accepted.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command(collection, {"create": f"{collection.name}_new"}, session=session) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_createIndexes_on_new_collection_runs_in_transaction(collection): + """createIndexes is accepted on a collection created earlier in the same transaction.""" + client = collection.database.client + new_collection = f"{collection.name}_new" + with client.start_session() as session: + session.start_transaction() + execute_command(collection, {"create": new_collection}, session=session) + result = execute_command( + collection, + {"createIndexes": new_collection, "indexes": [{"key": {"x": 1}, "name": "x_1"}]}, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_near_query_runs_in_transaction(collection): + """A $near query runs inside a transaction (with a 2dsphere index).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}} + }, + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_nearSphere_query_runs_in_transaction(collection): + """A $nearSphere query runs inside a transaction (with a 2dsphere index).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "loc": {"$nearSphere": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}} + }, + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) + + +def test_geoNear_stage_runs_in_transaction(collection): + """A $geoNear aggregation stage runs inside a transaction (with a 2dsphere index).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$geoNear": { + "near": {"type": "Point", "coordinates": [0, 0]}, + "distanceField": "d", + "spherical": True, + } + } + ], + "cursor": {}, + }, + session=session, + ) + session.commit_transaction() + assertSuccessPartial(result, {"ok": 1.0}) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_txn_number_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_txn_number_error.py new file mode 100644 index 000000000..135bd9470 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_txn_number_error.py @@ -0,0 +1,131 @@ +"""Tests for commitTransaction txnNumber parameter validation. + +Validates type acceptance for the txnNumber parameter. Per the MongoDB +documentation, txnNumber is typed as long (Int64). On a replica set, +Int64 values produce NotARetryableWriteCommand (50768) when there is no +matching transaction, while negative values produce InvalidOptions (72). +Non-Int64 numeric types and non-numeric types produce TypeMismatch. Null +is treated as omitted (falls through to the no-transaction error). +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + INVALID_OPTIONS_ERROR, + NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [txnNumber Int64 Acceptance]: Int64 values are accepted for txnNumber. +TXN_NUMBER_INT64_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_int64_positive", + command={"commitTransaction": 1, "txnNumber": Int64(1)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="commitTransaction should accept txnNumber:Int64(1) and fail", + ), + CommandTestCase( + "txn_number_int64_zero", + command={"commitTransaction": 1, "txnNumber": Int64(0)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="commitTransaction should accept txnNumber:Int64(0) and fail", + ), + CommandTestCase( + "txn_number_int64_max", + command={"commitTransaction": 1, "txnNumber": Int64(9_223_372_036_854_775_807)}, + error_code=NOT_A_RETRYABLE_WRITE_COMMAND_ERROR, + msg="commitTransaction should accept txnNumber:Int64 max value", + ), + CommandTestCase( + "txn_number_int64_negative", + command={"commitTransaction": 1, "txnNumber": Int64(-1)}, + error_code=INVALID_OPTIONS_ERROR, + msg="commitTransaction should reject negative txnNumber with InvalidOptions", + ), +] + +# Property [txnNumber Type Strictness]: non-Int64 types are rejected with TypeMismatch. +TXN_NUMBER_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_int32", + command={"commitTransaction": 1, "txnNumber": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:int32 as wrong type", + ), + CommandTestCase( + "txn_number_double_whole", + command={"commitTransaction": 1, "txnNumber": 1.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:double (whole) as wrong type", + ), + CommandTestCase( + "txn_number_double_fractional", + command={"commitTransaction": 1, "txnNumber": 1.5}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:double (fractional) as wrong type", + ), + CommandTestCase( + "txn_number_decimal128", + command={"commitTransaction": 1, "txnNumber": Decimal128("1")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:Decimal128 as wrong type", + ), + CommandTestCase( + "txn_number_string", + command={"commitTransaction": 1, "txnNumber": "1"}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:string as wrong type", + ), + CommandTestCase( + "txn_number_bool", + command={"commitTransaction": 1, "txnNumber": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:bool as wrong type", + ), + CommandTestCase( + "txn_number_object", + command={"commitTransaction": 1, "txnNumber": {}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:{} (object) as wrong type", + ), + CommandTestCase( + "txn_number_array", + command={"commitTransaction": 1, "txnNumber": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject txnNumber:[] (array) as wrong type", + ), +] + +# Property [txnNumber Null Handling]: null txnNumber is treated as omitted. +TXN_NUMBER_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "txn_number_null", + command={"commitTransaction": 1, "txnNumber": None}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should treat txnNumber:null as omitted", + ), +] + +TXN_NUMBER_TESTS: list[CommandTestCase] = ( + TXN_NUMBER_INT64_TESTS + TXN_NUMBER_TYPE_REJECTION_TESTS + TXN_NUMBER_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(TXN_NUMBER_TESTS)) +def test_commitTransaction_txn_number_error(collection, test): + """Test commitTransaction txnNumber parameter validation.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_unrecognized_fields_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_unrecognized_fields_error.py new file mode 100644 index 000000000..0c591208a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_unrecognized_fields_error.py @@ -0,0 +1,106 @@ +"""Tests for commitTransaction unrecognized field handling. + +Validates that the commitTransaction command rejects unknown fields. Covers +single unknown fields, multiple unknown fields, case-sensitive field names, +known fields from other commands, and dollar-prefixed fields. +""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import UNRECOGNIZED_COMMAND_FIELD_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# Property [Unrecognized Field Rejection]: unknown fields are rejected. +UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_single_field", + command={"commitTransaction": 1, "unknownField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject single unknown field", + ), + CommandTestCase( + "unknown_multiple_fields", + command={"commitTransaction": 1, "foo": 1, "bar": 2}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject multiple unknown fields", + ), +] + +# Property [Case Sensitivity]: field names are case-sensitive and wrong-case variants are rejected. +CASE_SENSITIVITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "case_WriteConcern", + command={"commitTransaction": 1, "WriteConcern": {"w": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'WriteConcern' (capital W) as unrecognized", + ), + CommandTestCase( + "case_Autocommit", + command={"commitTransaction": 1, "Autocommit": False}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'Autocommit' (capital A) as unrecognized", + ), + CommandTestCase( + "case_TxnNumber", + command={"commitTransaction": 1, "TxnNumber": Int64(1)}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'TxnNumber' (capital T) as unrecognized", + ), + CommandTestCase( + "case_Comment", + command={"commitTransaction": 1, "Comment": "test"}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'Comment' (capital C) as unrecognized", + ), +] + +# Property [Foreign Field Rejection]: fields from other commands are rejected. +FOREIGN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "foreign_query", + command={"commitTransaction": 1, "query": {"x": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject 'query' field from other commands", + ), + CommandTestCase( + "dollar_prefixed", + command={"commitTransaction": 1, "$unknown": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject dollar-prefixed unknown field", + ), +] + +# Property [writeConcern Unknown Sub-Field]: unknown writeConcern sub-fields are rejected. +WRITECONCERN_UNKNOWN_SUBFIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wc_unknown_subfield", + command={"commitTransaction": 1, "writeConcern": {"w": 1, "unknownOption": True}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="commitTransaction should reject unknown writeConcern sub-field", + ), +] + +UNRECOGNIZED_TESTS: list[CommandTestCase] = ( + UNRECOGNIZED_FIELD_TESTS + + CASE_SENSITIVITY_TESTS + + FOREIGN_FIELD_TESTS + + WRITECONCERN_UNKNOWN_SUBFIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(UNRECOGNIZED_TESTS)) +def test_commitTransaction_unrecognized_fields_error(collection, test): + """Test commitTransaction unrecognized field handling.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_visibility.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_visibility.py new file mode 100644 index 000000000..445736b7d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_visibility.py @@ -0,0 +1,40 @@ +"""Tests for the visibility of a transaction's pending writes before commit. + +A write made inside an open transaction must not be visible to readers outside +the transaction until it commits, but must be visible to reads issued within +the same session. Each test observes the pending write before committing, then +commits to end the transaction. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_pending_write_not_visible_outside_before_commit(collection): + """A pending insert is not visible to a reader outside the transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1, "x": 1}, session=session) + outside = execute_command(collection, {"find": collection.name, "filter": {}}) + session.commit_transaction() + assertSuccess(outside, []) + + +def test_pending_write_visible_in_session_before_commit(collection): + """A pending insert is visible to a read in the same session before commit.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1, "x": 1}, session=session) + in_session = execute_command( + collection, {"find": collection.name, "filter": {}}, session=session + ) + session.commit_transaction() + assertSuccess(in_session, [{"_id": 1, "x": 1}]) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_write_conflict.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_write_conflict.py new file mode 100644 index 000000000..27208186c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_write_conflict.py @@ -0,0 +1,34 @@ +"""Test write-conflict behavior for concurrent transactions. + +When two open transactions write to the same document, the second writer fails +immediately with WriteConflict rather than blocking (first-committer-wins). The +first transaction commits; the conflicting session is left for its ``with`` +block to close. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import WRITE_CONFLICT_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +def test_second_writer_to_open_doc_fails_with_write_conflict(collection): + """A second writer to a doc already modified in an open txn fails with WriteConflict.""" + collection.insert_one({"_id": 1, "x": 0}) + client = collection.database.client + with client.start_session() as first, client.start_session() as second: + first.start_transaction() + collection.update_one({"_id": 1}, {"$set": {"x": 1}}, session=first) + second.start_transaction() + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$set": {"x": 2}}}]}, + session=second, + ) + first.commit_transaction() + assertFailureCode(result, WRITE_CONFLICT_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern.py new file mode 100644 index 000000000..12a254216 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern.py @@ -0,0 +1,192 @@ +"""Tests for commitTransaction writeConcern parameter acceptance in a real transaction. + +Validates that accepted writeConcern variants (document types, w sub-field +values, j sub-field values, wtimeout sub-field values, and edge cases) succeed +when commitTransaction is issued inside an active transaction on a replica set. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + +# Property [writeConcern Document Acceptance]: writeConcern accepts document values. +WRITECONCERN_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_empty_doc", + command={"commitTransaction": 1, "writeConcern": {}}, + msg="commitTransaction should accept empty writeConcern document", + ), + CommandTestCase( + "writeconcern_null", + command={"commitTransaction": 1, "writeConcern": None}, + msg="commitTransaction should accept writeConcern:null", + ), + CommandTestCase( + "wc_combined_w_j_wtimeout", + command={ + "commitTransaction": 1, + "writeConcern": {"w": "majority", "j": True, "wtimeout": 10_000}, + }, + msg="commitTransaction should accept combined w + j + wtimeout", + ), + CommandTestCase( + "wc_w0_j_true", + command={"commitTransaction": 1, "writeConcern": {"w": 0, "j": True}}, + msg="commitTransaction should accept conflicting w:0 with j:true", + ), + CommandTestCase( + "wc_fsync_true", + command={"commitTransaction": 1, "writeConcern": {"fsync": True}}, + msg="commitTransaction should accept legacy writeConcern.fsync:true", + ), +] + +# Property [w Accepted Values]: w accepts int and string "majority" values. +W_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_int32_one", + command={"commitTransaction": 1, "writeConcern": {"w": 1}}, + msg="commitTransaction should accept writeConcern.w:1", + ), + CommandTestCase( + "w_int32_zero", + command={"commitTransaction": 1, "writeConcern": {"w": 0}}, + msg="commitTransaction should accept writeConcern.w:0 (unacknowledged)", + ), + CommandTestCase( + "w_majority", + command={"commitTransaction": 1, "writeConcern": {"w": "majority"}}, + msg="commitTransaction should accept writeConcern.w:'majority'", + ), + CommandTestCase( + "w_int64", + command={"commitTransaction": 1, "writeConcern": {"w": Int64(1)}}, + msg="commitTransaction should accept writeConcern.w:Int64(1)", + ), + CommandTestCase( + "w_double_whole", + command={"commitTransaction": 1, "writeConcern": {"w": 1.0}}, + msg="commitTransaction should accept writeConcern.w:1.0", + ), + CommandTestCase( + "w_double_fractional", + command={"commitTransaction": 1, "writeConcern": {"w": 1.5}}, + msg="commitTransaction should accept writeConcern.w:1.5", + ), + CommandTestCase( + "w_decimal128", + command={"commitTransaction": 1, "writeConcern": {"w": Decimal128("1")}}, + msg="commitTransaction should accept writeConcern.w:Decimal128('1')", + ), +] + +# Property [j Accepted Values]: j accepts boolean and numeric types. +J_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "j_bool_true", + command={"commitTransaction": 1, "writeConcern": {"j": True}}, + msg="commitTransaction should accept writeConcern.j:true", + ), + CommandTestCase( + "j_bool_false", + command={"commitTransaction": 1, "writeConcern": {"j": False}}, + msg="commitTransaction should accept writeConcern.j:false", + ), + CommandTestCase( + "j_int32_one", + command={"commitTransaction": 1, "writeConcern": {"j": 1}}, + msg="commitTransaction should accept writeConcern.j:1 (coerced to true)", + ), + CommandTestCase( + "j_int32_zero", + command={"commitTransaction": 1, "writeConcern": {"j": 0}}, + msg="commitTransaction should accept writeConcern.j:0 (coerced to false)", + ), + CommandTestCase( + "j_null", + command={"commitTransaction": 1, "writeConcern": {"j": None}}, + msg="commitTransaction should accept writeConcern.j:null", + ), +] + +# Property [wtimeout Accepted Values]: wtimeout accepts numeric types broadly. +WTIMEOUT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_int32_positive", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": 1000}}, + msg="commitTransaction should accept writeConcern.wtimeout:1000", + ), + CommandTestCase( + "wtimeout_int32_zero", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": 0}}, + msg="commitTransaction should accept writeConcern.wtimeout:0 (no timeout)", + ), + CommandTestCase( + "wtimeout_int64", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": Int64(1000)}}, + msg="commitTransaction should accept writeConcern.wtimeout:Int64(1000)", + ), + CommandTestCase( + "wtimeout_double_whole", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": 1000.0}}, + msg="commitTransaction should accept writeConcern.wtimeout:1000.0", + ), + CommandTestCase( + "wtimeout_negative", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": -1}}, + msg="commitTransaction should accept writeConcern.wtimeout:-1", + ), + CommandTestCase( + "wtimeout_string", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": "1000"}}, + msg="commitTransaction should accept writeConcern.wtimeout:'1000'", + ), + CommandTestCase( + "wtimeout_bool", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": True}}, + msg="commitTransaction should accept writeConcern.wtimeout:true", + ), + CommandTestCase( + "wtimeout_null", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": None}}, + msg="commitTransaction should accept writeConcern.wtimeout:null", + ), + CommandTestCase( + "wtimeout_object", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": {}}}, + msg="commitTransaction should accept writeConcern.wtimeout:{}", + ), + CommandTestCase( + "wtimeout_array", + command={"commitTransaction": 1, "writeConcern": {"wtimeout": []}}, + msg="commitTransaction should accept writeConcern.wtimeout:[]", + ), +] + +WRITECONCERN_TESTS: list[CommandTestCase] = ( + WRITECONCERN_ACCEPTANCE_TESTS + + W_ACCEPTANCE_TESTS + + J_ACCEPTANCE_TESTS + + WTIMEOUT_ACCEPTANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(WRITECONCERN_TESTS)) +def test_commitTransaction_writeconcern(collection, test): + """Test commitTransaction writeConcern parameter acceptance in a transaction.""" + client = collection.database.client + with client.start_session() as session: + session.start_transaction() + collection.insert_one({"_id": 1}, session=session) + result = execute_admin_command(collection, test.command, session=session) + assertSuccessPartial(result, {"ok": 1.0}, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern_error.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern_error.py new file mode 100644 index 000000000..832623c01 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_commitTransaction_writeconcern_error.py @@ -0,0 +1,256 @@ +"""Tests for commitTransaction writeConcern parameter error cases. + +Validates that invalid writeConcern types and sub-field values are rejected +with the appropriate error codes. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_FAILED_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.requires(transactions=True)] + + +# --------------------------------------------------------------------------- +# Property [writeConcern Type Rejection]: non-document types are rejected with TypeMismatch. +# --------------------------------------------------------------------------- + +WRITECONCERN_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_string", + command={"commitTransaction": 1, "writeConcern": "majority"}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:string as wrong type", + ), + CommandTestCase( + "writeconcern_int32", + command={"commitTransaction": 1, "writeConcern": 1}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:int32 as wrong type", + ), + CommandTestCase( + "writeconcern_int64", + command={"commitTransaction": 1, "writeConcern": Int64(1)}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Int64 as wrong type", + ), + CommandTestCase( + "writeconcern_double", + command={"commitTransaction": 1, "writeConcern": 1.0}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:double as wrong type", + ), + CommandTestCase( + "writeconcern_decimal128", + command={"commitTransaction": 1, "writeConcern": Decimal128("1")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Decimal128 as wrong type", + ), + CommandTestCase( + "writeconcern_bool_true", + command={"commitTransaction": 1, "writeConcern": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:true as wrong type", + ), + CommandTestCase( + "writeconcern_bool_false", + command={"commitTransaction": 1, "writeConcern": False}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:false as wrong type", + ), + CommandTestCase( + "writeconcern_array_empty", + command={"commitTransaction": 1, "writeConcern": []}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:[] as wrong type", + ), + CommandTestCase( + "writeconcern_array_nonempty", + command={"commitTransaction": 1, "writeConcern": [{"w": 1}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:[{w:1}] as wrong type", + ), + CommandTestCase( + "writeconcern_binary", + command={"commitTransaction": 1, "writeConcern": Binary(b"\x00")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Binary as wrong type", + ), + CommandTestCase( + "writeconcern_objectid", + command={"commitTransaction": 1, "writeConcern": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:ObjectId as wrong type", + ), + CommandTestCase( + "writeconcern_datetime", + command={"commitTransaction": 1, "writeConcern": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:datetime as wrong type", + ), + CommandTestCase( + "writeconcern_regex", + command={"commitTransaction": 1, "writeConcern": Regex(".*")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Regex as wrong type", + ), + CommandTestCase( + "writeconcern_timestamp", + command={"commitTransaction": 1, "writeConcern": Timestamp(0, 0)}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Timestamp as wrong type", + ), + CommandTestCase( + "writeconcern_code", + command={"commitTransaction": 1, "writeConcern": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:Code as wrong type", + ), + CommandTestCase( + "writeconcern_minkey", + command={"commitTransaction": 1, "writeConcern": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:MinKey as wrong type", + ), + CommandTestCase( + "writeconcern_maxkey", + command={"commitTransaction": 1, "writeConcern": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern:MaxKey as wrong type", + ), +] + + +# --------------------------------------------------------------------------- +# Property [w Invalid Values]: invalid w values are rejected with CommandFailed or FailedToParse. +# --------------------------------------------------------------------------- + +W_INVALID_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "w_custom_tag", + command={"commitTransaction": 1, "writeConcern": {"w": "myTag"}}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should reject writeConcern.w:'myTag' with CommandFailed", + ), + CommandTestCase( + "w_empty_string", + command={"commitTransaction": 1, "writeConcern": {"w": ""}}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should reject writeConcern.w:'' with CommandFailed", + ), + CommandTestCase( + "w_null", + command={"commitTransaction": 1, "writeConcern": {"w": None}}, + error_code=COMMAND_FAILED_ERROR, + msg="commitTransaction should reject writeConcern.w:null with CommandFailed", + ), + CommandTestCase( + "w_negative_int", + command={"commitTransaction": 1, "writeConcern": {"w": -1}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:-1 with FailedToParse", + ), + CommandTestCase( + "w_int32_max", + command={"commitTransaction": 1, "writeConcern": {"w": 2_147_483_647}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:INT32_MAX with FailedToParse", + ), + CommandTestCase( + "w_bool_false", + command={"commitTransaction": 1, "writeConcern": {"w": False}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:false with FailedToParse", + ), + CommandTestCase( + "w_bool_true", + command={"commitTransaction": 1, "writeConcern": {"w": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:true with FailedToParse", + ), + CommandTestCase( + "w_object", + command={"commitTransaction": 1, "writeConcern": {"w": {}}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:{} with FailedToParse", + ), + CommandTestCase( + "w_array", + command={"commitTransaction": 1, "writeConcern": {"w": []}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.w:[] with FailedToParse", + ), +] + + +# --------------------------------------------------------------------------- +# Property [j Type Rejection]: non-boolean non-numeric types are rejected with TypeMismatch. +# --------------------------------------------------------------------------- + +J_TYPE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "j_string", + command={"commitTransaction": 1, "writeConcern": {"j": "true"}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern.j:'true' as wrong type", + ), + CommandTestCase( + "j_object", + command={"commitTransaction": 1, "writeConcern": {"j": {}}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern.j:{} as wrong type", + ), + CommandTestCase( + "j_array", + command={"commitTransaction": 1, "writeConcern": {"j": []}}, + error_code=TYPE_MISMATCH_ERROR, + msg="commitTransaction should reject writeConcern.j:[] as wrong type", + ), +] + + +# --------------------------------------------------------------------------- +# Property [wtimeout Overflow]: Int64 max value overflows and produces FailedToParse. +# --------------------------------------------------------------------------- + +WTIMEOUT_OVERFLOW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wtimeout_int64_max", + command={ + "commitTransaction": 1, + "writeConcern": {"wtimeout": Int64(9_223_372_036_854_775_807)}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="commitTransaction should reject writeConcern.wtimeout:Int64 max with FailedToParse", + ), +] + + +WRITECONCERN_ERROR_TESTS: list[CommandTestCase] = ( + WRITECONCERN_TYPE_REJECTION_TESTS + + W_INVALID_VALUE_TESTS + + J_TYPE_REJECTION_TESTS + + WTIMEOUT_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(WRITECONCERN_ERROR_TESTS)) +def test_commitTransaction_writeconcern_error(collection, test): + """Test commitTransaction writeConcern parameter error cases.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_smoke_commitTransaction.py b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_smoke_commitTransaction.py index 3fc6da5e3..9d95e6566 100644 --- a/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_smoke_commitTransaction.py +++ b/documentdb_tests/compatibility/tests/core/sessions/commands/commitTransaction/test_smoke_commitTransaction.py @@ -9,7 +9,7 @@ from documentdb_tests.framework.assertions import assertFailure from documentdb_tests.framework.executor import execute_admin_command -pytestmark = pytest.mark.smoke +pytestmark = [pytest.mark.smoke, pytest.mark.requires(transactions=True)] def test_smoke_commitTransaction(collection): diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index fff507521..1456e8ed9 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -37,6 +37,7 @@ INDEX_OPTIONS_CONFLICT_ERROR = 85 INDEX_KEY_SPECS_CONFLICT_ERROR = 86 OPERATION_FAILED_ERROR = 96 +WRITE_CONFLICT_ERROR = 112 COMMAND_NOT_SUPPORTED_ERROR = 115 DOCUMENT_VALIDATION_FAILURE_ERROR = 121 NOT_A_REPLICA_SET_ERROR = 123 From 3979cb499fa2990a9870d54e9093ff8994d37d60 Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:20:44 -0700 Subject: [PATCH 43/51] Add getClusterParameter tests (#656) Signed-off-by: PatersonProjects --- .../commands/getClusterParameter/__init__.py | 1 + ...t_getClusterParameter_argument_handling.py | 92 ++++++++++++ .../test_getClusterParameter_core_behavior.py | 41 ++++++ .../test_getClusterParameter_errors.py | 136 ++++++++++++++++++ ..._getClusterParameter_response_structure.py | 60 ++++++++ .../utils/administration_test_case.py | 19 +++ documentdb_tests/framework/property_checks.py | 19 +++ 7 files changed, 368 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/utils/administration_test_case.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/__init__.py new file mode 100644 index 000000000..e07018905 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/__init__.py @@ -0,0 +1 @@ +"""getClusterParameter command tests.""" diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_argument_handling.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_argument_handling.py new file mode 100644 index 000000000..3251bfafb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_argument_handling.py @@ -0,0 +1,92 @@ +"""Tests for getClusterParameter argument handling. + +Covers accepted argument forms (single string, wildcard, array of +strings, duplicate names, unrecognized extra field) and BSON type +rejection for the command argument. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.administration_test_case import ( # noqa: E501 + AdministrationTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Len +from documentdb_tests.framework.test_constants import BsonType + +pytestmark = pytest.mark.admin + +_VALID_PARAM = "changeStreamOptions" +_VALID_PARAM_2 = "defaultMaxTimeMS" + +_ARGUMENT_TYPE_SPEC = [ + BsonTypeTestCase( + id="getClusterParameter_argument", + msg="getClusterParameter should reject non-string/non-array argument types", + keyword="getClusterParameter", + valid_types=[BsonType.STRING, BsonType.ARRAY], + default_error_code=TYPE_MISMATCH_ERROR, + skip_rejection_types=[BsonType.NULL], + ), +] + +_ARGUMENT_REJECTION_CASES = generate_bson_rejection_test_cases(_ARGUMENT_TYPE_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", _ARGUMENT_REJECTION_CASES) +def test_getClusterParameter_argument_rejects_type(collection, bson_type, sample_value, spec): + """Test getClusterParameter rejects non-string/non-array argument types.""" + result = execute_admin_command(collection, {"getClusterParameter": sample_value}) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"getClusterParameter should reject {bson_type.value} argument.", + ) + + +ARGUMENT_FORM_TESTS: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="wildcard_returns_all", + command={"getClusterParameter": "*"}, + checks={"ok": Eq(1.0)}, + msg="Wildcard '*' should return ok:1", + ), + AdministrationTestCase( + id="single_name_returns_one", + command={"getClusterParameter": _VALID_PARAM}, + checks={"ok": Eq(1.0), "clusterParameters": Len(1)}, + msg="Single name should return ok:1 with one parameter", + ), + AdministrationTestCase( + id="array_two_names_returns_two", + command={"getClusterParameter": [_VALID_PARAM, _VALID_PARAM_2]}, + checks={"ok": Eq(1.0), "clusterParameters": Len(2)}, + msg="Array of two names should return two parameters", + ), + AdministrationTestCase( + id="array_duplicate_names", + command={"getClusterParameter": [_VALID_PARAM, _VALID_PARAM]}, + checks={"ok": Eq(1.0)}, + msg="Duplicate names in array should succeed", + ), + AdministrationTestCase( + id="unrecognized_field_accepted", + command={"getClusterParameter": "*", "unknownField": "test"}, + checks={"ok": Eq(1.0)}, + msg="Unrecognized extra field should be accepted", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_FORM_TESTS)) +def test_getClusterParameter_argument_forms(collection, test): + """Test accepted argument forms each return ok:1 with expected clusterParameters length.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_core_behavior.py new file mode 100644 index 000000000..10b317b59 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_core_behavior.py @@ -0,0 +1,41 @@ +"""Tests for getClusterParameter core retrieval behavior. + +Verifies that the wildcard returns more than one cluster parameter and +that the result includes known parameters by name. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.administration_test_case import ( # noqa: E501 + AdministrationTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Contains, LenGt + +pytestmark = pytest.mark.admin + +_VALID_PARAM = "changeStreamOptions" + +CORE_BEHAVIOR_TESTS: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="wildcard_returns_multiple_params", + command={"getClusterParameter": "*"}, + checks={"clusterParameters": LenGt(1)}, + msg="Wildcard should return more than one cluster parameter", + ), + AdministrationTestCase( + id="wildcard_includes_known_param", + command={"getClusterParameter": "*"}, + checks={"clusterParameters": Contains("_id", _VALID_PARAM)}, + msg=f"Wildcard result should include '{_VALID_PARAM}'", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CORE_BEHAVIOR_TESTS)) +def test_getClusterParameter_core_behavior(collection, test): + """Test core retrieval behavior: wildcard parameter count and inclusion.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_errors.py new file mode 100644 index 000000000..3d5992c4f --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_errors.py @@ -0,0 +1,136 @@ +"""Tests for getClusterParameter error cases. + +Covers error-producing inputs: unknown parameter names, empty and +null arguments, array element type errors, command-key case +sensitivity, and key ordering enforcement. Also verifies that the +command is rejected on non-admin databases. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.administration_test_case import ( # noqa: E501 + AdministrationTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + COMMAND_NOT_FOUND_ERROR, + NO_SUCH_KEY_ERROR, + TYPE_MISMATCH_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + +_VALID_PARAM = "changeStreamOptions" + + +_NO_SUCH_KEY_CASES: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="unknown_single_string_errors", + command={"getClusterParameter": "unknownParam"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Single unknown name should fail with no-such-parameter error", + ), + AdministrationTestCase( + id="empty_string_argument", + command={"getClusterParameter": ""}, + error_code=NO_SUCH_KEY_ERROR, + msg="Empty-string argument should be treated as an unknown parameter name", + ), + AdministrationTestCase( + id="case_altered", + command={"getClusterParameter": "ChangeStreamOptions"}, + error_code=NO_SUCH_KEY_ERROR, + msg="Altered-case known name should not match (case-sensitive)", + ), + AdministrationTestCase( + id="star_in_array_is_literal_name", + command={"getClusterParameter": ["*"]}, + error_code=NO_SUCH_KEY_ERROR, + msg="'*' inside an array is a literal name, not a wildcard", + ), + AdministrationTestCase( + id="array_mixed_valid_unknown_errors", + command={"getClusterParameter": [_VALID_PARAM, "unknownParam"]}, + error_code=NO_SUCH_KEY_ERROR, + msg="Unknown entry in mixed array should fail", + ), +] + +_TYPE_ERROR_CASES: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="empty_array_errors", + command={"getClusterParameter": []}, + error_code=BAD_VALUE_ERROR, + msg="Empty array must supply at least one name", + ), + AdministrationTestCase( + id="array_nonstring_element_rejects", + command={"getClusterParameter": [_VALID_PARAM, 123]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Non-string array element should be rejected", + ), + AdministrationTestCase( + id="null_argument", + command={"getClusterParameter": None}, + error_code=TYPE_MISMATCH_ERROR, + msg="Null argument should be rejected as a type mismatch", + ), + AdministrationTestCase( + id="array_null_element_rejects", + command={"getClusterParameter": [None]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Null element in array should be rejected", + ), + AdministrationTestCase( + id="array_doc_element_rejects", + command={"getClusterParameter": [{"a": 1}]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Document element in array should be rejected", + ), + AdministrationTestCase( + id="array_nested_array_rejects", + command={"getClusterParameter": [[_VALID_PARAM]]}, + error_code=TYPE_MISMATCH_ERROR, + msg="Nested array element should be rejected", + ), +] + +_COMMAND_ROUTING_CASES: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="wrong_case_command_key_rejected", + command={"getclusterparameter": "*"}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Wrong-case command key should be rejected", + ), + AdministrationTestCase( + id="command_key_not_first_fails", + command={"comment": "test", "getClusterParameter": "*"}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="getClusterParameter must be the first key in the command document", + ), +] + +_ERROR_CASES: list[AdministrationTestCase] = ( + _NO_SUCH_KEY_CASES + _TYPE_ERROR_CASES + _COMMAND_ROUTING_CASES +) + + +@pytest.mark.parametrize("test", pytest_params(_ERROR_CASES)) +def test_getClusterParameter_errors(collection, test): + """Test all error-producing inputs: unknown names, type mismatches, and command routing.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_getClusterParameter_rejected_on_non_admin_database(collection): + """Test getClusterParameter is rejected against a non-admin database.""" + result = execute_command(collection, {"getClusterParameter": "*"}) + assertFailureCode( + result, + UNAUTHORIZED_ERROR, + msg="getClusterParameter should be rejected on a non-admin database.", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_response_structure.py b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_response_structure.py new file mode 100644 index 000000000..1530b9a06 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/getClusterParameter/test_getClusterParameter_response_structure.py @@ -0,0 +1,60 @@ +"""Tests for getClusterParameter response structure. + +Verifies the top-level shape of the success response: ok is 1, +clusterParameters is an array, and a single-name request returns +exactly one element. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.utils.administration_test_case import ( # noqa: E501 + AdministrationTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Contains, Eq, IsType, Len + +pytestmark = pytest.mark.admin + +_VALID_PARAM = "changeStreamOptions" + +PROPERTY_TESTS: list[AdministrationTestCase] = [ + AdministrationTestCase( + id="ok_is_1", + command={"getClusterParameter": "*"}, + checks={"ok": Eq(1.0)}, + msg="Response ok should be 1.0", + ), + AdministrationTestCase( + id="clusterParameters_is_array", + command={"getClusterParameter": "*"}, + checks={"clusterParameters": IsType("array")}, + msg="clusterParameters should be an array", + ), + AdministrationTestCase( + id="single_name_length_is_one", + command={"getClusterParameter": _VALID_PARAM}, + checks={"clusterParameters": Len(1)}, + msg="Single-name request should return exactly one element", + ), + AdministrationTestCase( + id="element_id_matches_request", + command={"getClusterParameter": _VALID_PARAM}, + checks={"clusterParameters.0._id": Eq(_VALID_PARAM)}, + msg=f"Single-name request should return element with _id equal to '{_VALID_PARAM}'", + ), + AdministrationTestCase( + id="wildcard_includes_fleDisableSubstringPreviewParameterLimits", + command={"getClusterParameter": "*"}, + checks={"clusterParameters": Contains("_id", "fleDisableSubstringPreviewParameterLimits")}, + msg="Wildcard result should include 'fleDisableSubstringPreviewParameterLimits'", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PROPERTY_TESTS)) +def test_getClusterParameter_response_properties(collection, test): + """Verifies getClusterParameter response fields have expected types and values.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/administration/utils/administration_test_case.py b/documentdb_tests/compatibility/tests/system/administration/utils/administration_test_case.py new file mode 100644 index 000000000..b9fbbd13c --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/utils/administration_test_case.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class AdministrationTestCase(BaseTestCase): + """Test case for administration command tests. + + Attributes: + command: The command dict to execute. + checks: Mapping of dotted field paths to property check objects. + """ + + command: Optional[Dict[str, Any]] = None + checks: Dict[str, Any] = field(default_factory=dict) diff --git a/documentdb_tests/framework/property_checks.py b/documentdb_tests/framework/property_checks.py index ef80dfb80..e61b5c32e 100644 --- a/documentdb_tests/framework/property_checks.py +++ b/documentdb_tests/framework/property_checks.py @@ -165,6 +165,25 @@ def __repr__(self) -> str: return f"{type(self).__name__}({self.expected!r})" +class LenGt(Check): + """Assert that the field is a list with length strictly greater than a minimum.""" + + def __init__(self, minimum: int) -> None: + self.minimum = minimum + + def check(self, value: Any, path: str) -> str | None: + if value is _FIELD_ABSENT: + return f"expected '{path}' length > {self.minimum}, but field is missing" + if not isinstance(value, list): + return f"expected '{path}' to be a list, got {type(value).__name__}" + if len(value) <= self.minimum: + return f"expected '{path}' length > {self.minimum}, got {len(value)}" + return None + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.minimum!r})" + + class LenLte(Check): """Assert that the field is a list whose length is at most ``maximum``.""" From cceac4c0b53c676b8a5553d023ddbfecedc03113 Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:35:02 -0700 Subject: [PATCH 44/51] Add $ping tests (#616) Signed-off-by: PatersonProjects --- .../buildInfo/test_buildInfo_consistency.py | 8 --- .../diagnostic/commands/ping/__init__.py | 0 .../ping/test_ping_argument_handling.py | 71 +++++++++++++++++++ .../commands/ping/test_ping_core_behavior.py | 64 +++++++++++++++++ .../ping/test_ping_error_conditions.py | 54 ++++++++++++++ .../ping/test_ping_response_structure.py | 37 ++++++++++ 6 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_error_conditions.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py index 8b84638d2..084109813 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/buildInfo/test_buildInfo_consistency.py @@ -35,11 +35,3 @@ def test_buildInfo_same_result_any_database(collection): msg="Should return same result from any database", raw_res=True, ) - - -def test_buildInfo_nonexistent_database(collection): - """Test buildInfo succeeds when run on a non-existent database.""" - other_db = f"{collection.name}_nonexistent_db" - other_col = collection.database.client[other_db][collection.name] - result = execute_command(other_col, {"buildInfo": 1}) - assertSuccessPartial(result, {"ok": 1.0}, msg="Should succeed on non-existent database") diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_argument_handling.py new file mode 100644 index 000000000..f44608554 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_argument_handling.py @@ -0,0 +1,71 @@ +"""Tests for ping command argument handling. + +Validates that ping accepts any BSON type as its command value, as well as +numeric edge cases (negative int, zero, infinity). The command value does +not affect behavior — all inputs should return ok: 1. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import FLOAT_INFINITY + +pytestmark = pytest.mark.admin + + +PING_BSON_TYPE_SPECS = [ + BsonTypeTestCase( + id="ping_value", + msg="ping command should accept any BSON type as value", + valid_types=list(BsonType), + ), +] + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(PING_BSON_TYPE_SPECS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_ping_argument_types(collection, bson_type, sample_value, spec): + """Test that ping accepts various BSON types as command value.""" + result = execute_admin_command(collection, {"ping": sample_value}) + assertProperties(result, {"ok": Eq(1.0)}, msg=spec.msg, raw_res=True) + + +NUMERIC_EDGE_CASES: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="negative_int", + command={"ping": -1}, + checks={"ok": Eq(1.0)}, + msg="Should accept negative int", + ), + DiagnosticTestCase( + id="int_0", + command={"ping": 0}, + checks={"ok": Eq(1.0)}, + msg="Should accept int 0", + ), + DiagnosticTestCase( + id="infinity", + command={"ping": FLOAT_INFINITY}, + checks={"ok": Eq(1.0)}, + msg="Should accept infinity", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NUMERIC_EDGE_CASES)) +def test_ping_argument_numeric_edge_cases(collection, test): + """Test that ping accepts edge-case numeric values as command value.""" + result = execute_admin_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_core_behavior.py new file mode 100644 index 000000000..6ca0adb9d --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_core_behavior.py @@ -0,0 +1,64 @@ +"""Tests for ping command core behavior. + +Validates that ping succeeds on both admin and non-admin databases, +can be executed repeatedly in succession, and works after other commands +or write activity. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +DATABASE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="admin_database", + command={"ping": 1}, + use_admin=True, + checks={"ok": Eq(1.0)}, + msg="Should succeed on admin database", + ), + DiagnosticTestCase( + id="non_admin_database", + command={"ping": 1}, + use_admin=False, + checks={"ok": Eq(1.0)}, + msg="Should succeed on non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DATABASE_TESTS)) +def test_ping_on_database(collection, test): + """Test ping returns ok:1 on both admin and non-admin databases.""" + if test.use_admin: + result = execute_admin_command(collection, test.command) + else: + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_ping_multiple_times_in_succession(collection): + """Test ping can be executed multiple times in succession.""" + for _ in range(3): + result = execute_admin_command(collection, {"ping": 1}) + assertProperties( + result, {"ok": Eq(1.0)}, msg="Should succeed on repeated execution", raw_res=True + ) + + +def test_ping_after_write_activity(collection): + """Test ping returns ok:1 immediately after write activity.""" + collection.insert_many([{"_id": i, "data": "x" * 100} for i in range(100)]) + result = execute_admin_command(collection, {"ping": 1}) + assertProperties( + result, {"ok": Eq(1.0)}, msg="Should succeed after write activity", raw_res=True + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_error_conditions.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_error_conditions.py new file mode 100644 index 000000000..e46e12c5a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_error_conditions.py @@ -0,0 +1,54 @@ +"""Tests for ping command error conditions. + +Validates that invalid usages of ping produce appropriate errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_FOUND_ERROR, + UNKNOWN_PIPELINE_STAGE_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +ERROR_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="unrecognized_field", + command={"ping": 1, "unknownField": "test"}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Should reject unrecognized fields", + ), + DiagnosticTestCase( + id="case_sensitive", + command={"Ping": 1}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="Case-mismatched command name should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS)) +def test_ping_error_conditions(collection, test): + """Verifies ping rejects invalid usages with appropriate error codes.""" + result = execute_admin_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_ping_as_pipeline_stage(collection): + """Test that $ping is not a valid aggregation pipeline stage.""" + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": [{"$ping": 1}], "cursor": {}}, + ) + assertFailureCode( + result, UNKNOWN_PIPELINE_STAGE_ERROR, msg="ping should not be a valid pipeline stage" + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_response_structure.py new file mode 100644 index 000000000..4c8418369 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/ping/test_ping_response_structure.py @@ -0,0 +1,37 @@ +"""Tests for ping command response structure. + +Validates the response fields and their types. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType + +pytestmark = pytest.mark.admin + + +RESPONSE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="ok_field_value", + checks={"ok": Eq(1.0)}, + msg="'ok' field should be 1.0", + ), + DiagnosticTestCase( + id="ok_field_type", + checks={"ok": IsType("double")}, + msg="'ok' field should be a double", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_TESTS)) +def test_ping_response_properties(collection, test): + """Verifies ping response fields have expected types and values.""" + result = execute_admin_command(collection, {"ping": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) From 4844d324583b4335a6dfc33a81ce451666251da5 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:51:32 -0700 Subject: [PATCH 45/51] Add `setFeatureCompatibilityVersion` tests (#647) Signed-off-by: Alina (Xi) Li --- .../__init__.py | 0 ...tibilityVersion_confirm_semantics_error.py | 89 ++++++++ ...tibilityVersion_confirm_truthy_coercion.py | 102 +++++++++ ...lityVersion_confirm_type_coercion_error.py | 127 ++++++++++++ ...atureCompatibilityVersion_core_behavior.py | 139 +++++++++++++ ...t_setFeatureCompatibilityVersion_errors.py | 193 ++++++++++++++++++ ...patibilityVersion_feature_not_supported.py | 34 +++ ...tyVersion_version_type_validation_error.py | 81 ++++++++ ...yVersion_version_value_validation_error.py | 117 +++++++++++ ...patibilityVersion_writeConcern_accepted.py | 132 ++++++++++++ ...tyVersion_writeConcern_validation_error.py | 113 ++++++++++ ...st_smoke_setFeatureCompatibilityVersion.py | 24 +-- .../utils/__init__.py | 0 .../setFeatureCompatibilityVersion_common.py | 16 ++ documentdb_tests/framework/error_codes.py | 3 + 15 files changed, 1153 insertions(+), 17 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_semantics_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_truthy_coercion.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_type_coercion_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_feature_not_supported.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_type_validation_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_value_validation_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_accepted.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_validation_error.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/setFeatureCompatibilityVersion_common.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_semantics_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_semantics_error.py new file mode 100644 index 000000000..530e6e5b6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_semantics_error.py @@ -0,0 +1,89 @@ +"""Tests for setFeatureCompatibilityVersion confirm field semantics (error cases). + +Validates that omitting confirm or setting confirm:false prevents FCV changes. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import FCV_CONFIRM_REQUIRED_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Confirm Required]: version change without confirm (omitted or false) is rejected. +CONFIRM_REQUIRED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "downgrade_without_confirm", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV"}, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should reject downgrade without confirm field", + ), + CommandTestCase( + "confirm_false_rejects_change", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": False, + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should reject version change with confirm:false", + ), +] + +# Property [Upgrade Without Confirm]: upgrade without confirm is rejected. +UPGRADE_NO_CONFIRM_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upgrade_without_confirm", + command=lambda ctx: {"setFeatureCompatibilityVersion": "ORIGINAL_FCV"}, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should reject upgrade without confirm", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONFIRM_REQUIRED_TESTS)) +def test_setFeatureCompatibilityVersion_confirm_required(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects change without valid confirm.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = other + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(UPGRADE_NO_CONFIRM_TESTS)) +def test_setFeatureCompatibilityVersion_upgrade_without_confirm(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects upgrade when confirm is omitted.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + execute_admin_command(collection, {"setFeatureCompatibilityVersion": other, "confirm": True}) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = original + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": original, "confirm": True}) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_truthy_coercion.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_truthy_coercion.py new file mode 100644 index 000000000..6a976a43a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_truthy_coercion.py @@ -0,0 +1,102 @@ +"""Tests for setFeatureCompatibilityVersion confirm field truthy coercion. + +Validates that the confirm field accepts truthy numeric values +(int 1, double 1.0, Int64(1), Decimal128("1"), NaN, Infinity, -Infinity). +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Truthy Coercion]: confirm field accepts truthy numeric values. +CONFIRM_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "int_1", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV", "confirm": 1}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=1 (int) as true", + ), + CommandTestCase( + "double_1", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV", "confirm": 1.0}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=1.0 (double) as true", + ), + CommandTestCase( + "long_1", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": Int64(1), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=Int64(1) as true", + ), + CommandTestCase( + "decimal_1", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": Decimal128("1"), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=Decimal128('1') as true", + ), + CommandTestCase( + "nan", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": float("nan"), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=NaN as true", + ), + CommandTestCase( + "infinity", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": float("inf"), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=Infinity as true", + ), + CommandTestCase( + "negative_infinity", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": float("-inf"), + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept confirm=-Infinity as true", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONFIRM_TRUTHY_TESTS)) +def test_setFeatureCompatibilityVersion_confirm_truthy(database_client, collection, test): + """Test setFeatureCompatibilityVersion accepts truthy confirm values.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + current = get_fcv(collection) + other = "8.0" if current != "8.0" else "8.2" + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = other + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": current, "confirm": True}) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_type_coercion_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_type_coercion_error.py new file mode 100644 index 000000000..e1260e50e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_confirm_type_coercion_error.py @@ -0,0 +1,127 @@ +"""Tests for setFeatureCompatibilityVersion confirm field type coercion (error cases). + +Validates that the confirm field treats falsy values as false and rejects +non-numeric, non-bool types. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FCV_CONFIRM_REQUIRED_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Confirm Rejected]: confirm field treats falsy values as false +# and rejects non-numeric, non-bool types. +CONFIRM_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "int_0", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV", "confirm": 0}, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=0 as false", + ), + CommandTestCase( + "double_0", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": 0.0, + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=0.0 as false", + ), + CommandTestCase( + "long_0", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": Int64(0), + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=Int64(0) as false", + ), + CommandTestCase( + "decimal_0", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": Decimal128("0"), + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=Decimal128('0') as false", + ), + CommandTestCase( + "null", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": None, + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=null as not-true", + ), + CommandTestCase( + "negative_zero", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": -0.0, + }, + error_code=FCV_CONFIRM_REQUIRED_ERROR, + msg="setFeatureCompatibilityVersion should treat confirm=-0.0 as false", + ), + CommandTestCase( + "string_type", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": "true", + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject confirm as string type", + ), + CommandTestCase( + "object_type", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": {"a": 1}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject confirm as object type", + ), + CommandTestCase( + "array_type", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "OTHER_FCV", + "confirm": [True], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject confirm as array type", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONFIRM_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_confirm_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects invalid confirm values.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + current = get_fcv(collection) + other = "8.0" if current != "8.0" else "8.2" + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = other + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_core_behavior.py new file mode 100644 index 000000000..8757587fa --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_core_behavior.py @@ -0,0 +1,139 @@ +"""Tests for setFeatureCompatibilityVersion core behavior (success cases). + +Validates idempotent set, downgrade/upgrade with confirm, and +getParameter readback after change. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Idempotent Set]: setting the current version succeeds and returns ok:1. +IDEMPOTENT_SET_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "idempotent_set_current_version", + command=lambda ctx: {"setFeatureCompatibilityVersion": "CURRENT_FCV", "confirm": True}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed idempotently when re-setting" + " current version", + ), +] + + +# Property [Downgrade]: setFeatureCompatibilityVersion can downgrade with confirm:true. +DOWNGRADE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "downgrade_with_confirm", + command=lambda ctx: {"setFeatureCompatibilityVersion": "OTHER_FCV", "confirm": True}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed when downgrading version with confirm", + ), +] + + +# Property [Upgrade]: setFeatureCompatibilityVersion can upgrade back. +UPGRADE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upgrade_with_confirm", + command=lambda ctx: {"setFeatureCompatibilityVersion": "ORIGINAL_FCV", "confirm": True}, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed when upgrading version with confirm", + ), +] + + +# Property [GetParameter Reflects Change]: getParameter returns the new version after change. +GET_PARAMETER_AFTER_CHANGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "getParameter_reflects_change", + command=lambda ctx: {"getParameter": 1, "featureCompatibilityVersion": 1}, + expected={"ok": 1.0}, + msg="getParameter should return the new version after setFeatureCompatibilityVersion", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(IDEMPOTENT_SET_TESTS)) +def test_setFeatureCompatibilityVersion_idempotent_set(database_client, collection, test): + """Test setFeatureCompatibilityVersion succeeds when re-setting the current version.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + fcv = get_fcv(collection) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": fcv, "confirm": True}) + result = execute_admin_command( + collection, {"setFeatureCompatibilityVersion": fcv, "confirm": True} + ) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(DOWNGRADE_TESTS)) +def test_setFeatureCompatibilityVersion_downgrade(database_client, collection, test): + """Test setFeatureCompatibilityVersion can downgrade with confirm:true.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = other + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": original, "confirm": True}) + + +@pytest.mark.parametrize("test", pytest_params(UPGRADE_TESTS)) +def test_setFeatureCompatibilityVersion_upgrade(database_client, collection, test): + """Test setFeatureCompatibilityVersion can upgrade back to the original version.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + execute_admin_command(collection, {"setFeatureCompatibilityVersion": other, "confirm": True}) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = original + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(GET_PARAMETER_AFTER_CHANGE_TESTS)) +def test_setFeatureCompatibilityVersion_getParameter_reflects_change( + database_client, collection, test +): + """Test getParameter returns the new version after setFeatureCompatibilityVersion.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + original = get_fcv(collection) + other = "8.0" if original != "8.0" else "8.2" + execute_admin_command(collection, {"setFeatureCompatibilityVersion": other, "confirm": True}) + result = execute_admin_command(collection, test.build_command(ctx)) + expected = {"ok": 1.0, "featureCompatibilityVersion": {"version": other}} + assertSuccessPartial(result, expected, msg=test.msg) + execute_admin_command(collection, {"setFeatureCompatibilityVersion": original, "confirm": True}) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_errors.py new file mode 100644 index 000000000..59ec92d10 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_errors.py @@ -0,0 +1,193 @@ +"""Tests for setFeatureCompatibilityVersion error cases. + +Covers database enforcement (user/local/config DB rejection), unknown/extra fields, +error response structure, and setParameter rejection. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FCV_INVALID_VERSION_ERROR, + ILLEGAL_OPERATION_ERROR, + UNAUTHORIZED_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [User DB Rejected]: setFeatureCompatibilityVersion fails on a user database. +USER_DB_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "user_db_rejected", + error_code=UNAUTHORIZED_ERROR, + msg="setFeatureCompatibilityVersion should reject execution on a user database", + ), +] + + +# Property [System DB Rejected]: setFeatureCompatibilityVersion fails on local and config databases. +SYSTEM_DB_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "local_db_rejected", + error_code=UNAUTHORIZED_ERROR, + msg="setFeatureCompatibilityVersion should reject execution on the local database", + ), + CommandTestCase( + "config_db_rejected", + error_code=UNAUTHORIZED_ERROR, + msg="setFeatureCompatibilityVersion should reject execution on the config database", + ), +] + + +# Property [Unrecognized Fields]: setFeatureCompatibilityVersion rejects unrecognized fields. +UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unrecognized_field", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setFeatureCompatibilityVersion should reject unrecognized fields", + ), + CommandTestCase( + "misspelled_confirm", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confrim": True, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setFeatureCompatibilityVersion should reject misspelled 'confrim' as unknown field", + ), + CommandTestCase( + "writeConcern_unknown_subfield", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"unknownField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setFeatureCompatibilityVersion should reject unknown sub-fields in writeConcern", + ), +] + + +# Property [setParameter Rejected]: FCV cannot be set through setParameter. +SET_PARAMETER_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "via_setParameter", + command=lambda ctx: {"setParameter": 1, "featureCompatibilityVersion": "8.0"}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="setFeatureCompatibilityVersion should not be settable via setParameter", + ), +] + + +# Property [Error Contains Code]: invalid version returns FCV_INVALID_VERSION_ERROR. +ERROR_CODE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "invalid_version_returns_error", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "invalid_version", + "confirm": True, + }, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should return FCV_INVALID_VERSION_ERROR" + " for non-existent version string", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(USER_DB_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_user_db_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion fails on a user database.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + fcv = get_fcv(collection) + result = execute_command(collection, {"setFeatureCompatibilityVersion": fcv, "confirm": True}) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(SYSTEM_DB_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_system_db_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion fails on local and config databases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + fcv = get_fcv(collection) + db_name = "local" if "local" in test.id else "config" + target_collection = collection.database.client[db_name]["test"] + result = execute_command( + target_collection, {"setFeatureCompatibilityVersion": fcv, "confirm": True} + ) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(UNRECOGNIZED_FIELD_TESTS)) +def test_setFeatureCompatibilityVersion_unrecognized_field(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects unrecognized fields.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = get_fcv(collection) + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(SET_PARAMETER_TESTS)) +def test_setFeatureCompatibilityVersion_set_parameter_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion cannot be set through setParameter.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize("test", pytest_params(ERROR_CODE_TESTS)) +def test_setFeatureCompatibilityVersion_invalid_version_error(database_client, collection, test): + """Test setFeatureCompatibilityVersion returns FCV_INVALID_VERSION_ERROR.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_feature_not_supported.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_feature_not_supported.py new file mode 100644 index 000000000..fe750cd83 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_feature_not_supported.py @@ -0,0 +1,34 @@ +"""Tests for setFeatureCompatibilityVersion feature-not-supported behavior. + +Validates that setFeatureCompatibilityVersion is classified as an admin-only +command and returns the appropriate error when the feature is not supported. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import FEATURE_NOT_SUPPORTED_ERROR +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +def test_setFeatureCompatibilityVersion_unsupported_returns_303(collection): + """Test setFeatureCompatibilityVersion returns error code 303 when feature not supported.""" + result = execute_admin_command( + collection, {"setFeatureCompatibilityVersion": "8.0", "confirm": True} + ) + # This test is only meaningful on engines that do not support FCV (e.g., DocumentDB). + # On engines that support FCV, this test is expected to pass/succeed instead. + if ( + isinstance(result, Exception) + and hasattr(result, "code") + and result.code == FEATURE_NOT_SUPPORTED_ERROR + ): + assertFailureCode( + result, + FEATURE_NOT_SUPPORTED_ERROR, + msg="setFeatureCompatibilityVersion should return 303 when not supported", + ) + else: + pytest.skip("Engine supports setFeatureCompatibilityVersion, skipping not-supported test") diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_type_validation_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_type_validation_error.py new file mode 100644 index 000000000..41d9e87f8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_type_validation_error.py @@ -0,0 +1,81 @@ +"""Tests for setFeatureCompatibilityVersion version field BSON type validation (error cases). + +Validates that the version field rejects all non-string BSON types +with TYPE_MISMATCH_ERROR, and rejects null with MISSING_FIELD_ERROR. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import MISSING_FIELD_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +VERSION_TYPE_PARAM = [ + BsonTypeTestCase( + id="version_value", + msg="setFeatureCompatibilityVersion should only accept string for version", + keyword="setFeatureCompatibilityVersion", + valid_types=[BsonType.STRING], + skip_rejection_types=[BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + requires={"confirm": True}, + ), +] + +VERSION_TYPE_REJECTIONS = generate_bson_rejection_test_cases(VERSION_TYPE_PARAM) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", VERSION_TYPE_REJECTIONS) +def test_setFeatureCompatibilityVersion_version_type_rejected( + collection, bson_type, sample_value, spec +): + """Test setFeatureCompatibilityVersion rejects non-string BSON types for version.""" + result = execute_admin_command( + collection, + {"setFeatureCompatibilityVersion": sample_value, "confirm": True}, + ) + assertResult( + result, + error_code=spec.expected_code(bson_type), + msg=f"setFeatureCompatibilityVersion should reject {bson_type.value} for version", + raw_res=True, + ) + + +# Property [Null Rejected]: setFeatureCompatibilityVersion rejects null for version. +VERSION_NULL_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "version_null_rejected", + command=lambda ctx: {"setFeatureCompatibilityVersion": None, "confirm": True}, + error_code=MISSING_FIELD_ERROR, + msg="setFeatureCompatibilityVersion should reject null for version", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VERSION_NULL_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_version_null_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects null for version.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_value_validation_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_value_validation_error.py new file mode 100644 index 000000000..9c68b6059 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_version_value_validation_error.py @@ -0,0 +1,117 @@ +"""Tests for setFeatureCompatibilityVersion version value validation (error cases). + +Validates that the version field rejects unsupported, malformed, and +edge-case string values. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import FCV_INVALID_VERSION_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [Invalid Version Rejected]: setFeatureCompatibilityVersion rejects +# unsupported, malformed, and edge-case version strings. +VERSION_VALUE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "below_floor", + command=lambda ctx: {"setFeatureCompatibilityVersion": "3.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject version below supported floor", + ), + CommandTestCase( + "above_max", + command=lambda ctx: {"setFeatureCompatibilityVersion": "99.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject version above supported max", + ), + CommandTestCase( + "major_only", + command=lambda ctx: {"setFeatureCompatibilityVersion": "8", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject major-only version string", + ), + CommandTestCase( + "full_semver", + command=lambda ctx: {"setFeatureCompatibilityVersion": "8.0.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject full semver version string", + ), + CommandTestCase( + "leading_whitespace", + command=lambda ctx: {"setFeatureCompatibilityVersion": " 7.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject version with leading whitespace", + ), + CommandTestCase( + "trailing_whitespace", + command=lambda ctx: {"setFeatureCompatibilityVersion": "7.0 ", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject version with trailing whitespace", + ), + CommandTestCase( + "empty_string", + command=lambda ctx: {"setFeatureCompatibilityVersion": "", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject empty string version", + ), + CommandTestCase( + "zero_version", + command=lambda ctx: {"setFeatureCompatibilityVersion": "0.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject '0.0' as unsupported", + ), + CommandTestCase( + "future_version", + command=lambda ctx: {"setFeatureCompatibilityVersion": "10.0", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject future unsupported version", + ), + CommandTestCase( + "non_ascii", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "\uff18.\uff10", + "confirm": True, + }, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject non-ASCII version string", + ), + CommandTestCase( + "very_long_string", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "8" * 10_000, + "confirm": True, + }, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject a very long version string", + ), + CommandTestCase( + "intermediate_value", + command=lambda ctx: {"setFeatureCompatibilityVersion": "7.5", "confirm": True}, + error_code=FCV_INVALID_VERSION_ERROR, + msg="setFeatureCompatibilityVersion should reject intermediate version value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VERSION_VALUE_REJECTION_TESTS)) +def test_setFeatureCompatibilityVersion_version_value_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects invalid version values.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_accepted.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_accepted.py new file mode 100644 index 000000000..c4ec4661e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_accepted.py @@ -0,0 +1,132 @@ +"""Tests for setFeatureCompatibilityVersion writeConcern field acceptance. + +Validates that writeConcern accepts valid object values, null-as-omitted, +empty-doc, omission, and various numeric types for wtimeout. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [writeConcern Accepted]: setFeatureCompatibilityVersion accepts +# valid writeConcern values and various numeric types for wtimeout. +WRITE_CONCERN_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "object", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": 5000}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept writeConcern as object", + ), + CommandTestCase( + "empty_object", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept writeConcern as empty object", + ), + CommandTestCase( + "null_as_omitted", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": None, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should treat writeConcern=null as omitted", + ), + CommandTestCase( + "omitted", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed when writeConcern is omitted", + ), + CommandTestCase( + "wtimeout_double", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": 5000.0}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as double", + ), + CommandTestCase( + "wtimeout_long", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": Int64(5000)}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as Int64", + ), + CommandTestCase( + "wtimeout_decimal_whole", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": Decimal128("5000")}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as Decimal128", + ), + CommandTestCase( + "wtimeout_fractional_double", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": 5000.5}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as fractional double", + ), + CommandTestCase( + "wtimeout_negative", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": {"wtimeout": -1}, + }, + expected={"ok": 1.0}, + msg="setFeatureCompatibilityVersion should accept wtimeout as negative value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_ACCEPTED_TESTS)) +def test_setFeatureCompatibilityVersion_writeConcern_accepted(database_client, collection, test): + """Test setFeatureCompatibilityVersion accepts valid writeConcern values.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = get_fcv(collection) + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_validation_error.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_validation_error.py new file mode 100644 index 000000000..4855b7459 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_setFeatureCompatibilityVersion_writeConcern_validation_error.py @@ -0,0 +1,113 @@ +"""Tests for setFeatureCompatibilityVersion writeConcern field rejection (error cases). + +Validates that writeConcern rejects non-object types with TYPE_MISMATCH_ERROR. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setFeatureCompatibilityVersion_common import get_fcv + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel] + + +# Property [writeConcern Rejected]: setFeatureCompatibilityVersion rejects +# non-object writeConcern types. +WRITE_CONCERN_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "string", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": "majority", + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as string", + ), + CommandTestCase( + "int", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": 1, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as int", + ), + CommandTestCase( + "bool", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": True, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as bool", + ), + CommandTestCase( + "array", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": [{"w": 1}], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as array", + ), + CommandTestCase( + "long", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": Int64(1), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as long", + ), + CommandTestCase( + "double", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": 1.0, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as double", + ), + CommandTestCase( + "decimal128", + command=lambda ctx: { + "setFeatureCompatibilityVersion": "CURRENT_FCV", + "confirm": True, + "writeConcern": Decimal128("1"), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="setFeatureCompatibilityVersion should reject writeConcern as Decimal128", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_CONCERN_REJECTED_TESTS)) +def test_setFeatureCompatibilityVersion_writeConcern_rejected(database_client, collection, test): + """Test setFeatureCompatibilityVersion rejects non-object writeConcern types.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + cmd = test.build_command(ctx) + cmd["setFeatureCompatibilityVersion"] = get_fcv(collection) + result = execute_admin_command(collection, cmd) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_smoke_setFeatureCompatibilityVersion.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_smoke_setFeatureCompatibilityVersion.py index f343fb194..0c381df45 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_smoke_setFeatureCompatibilityVersion.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/test_smoke_setFeatureCompatibilityVersion.py @@ -9,33 +9,23 @@ from documentdb_tests.framework.assertions import assertSuccessPartial from documentdb_tests.framework.executor import execute_admin_command +from .utils.setFeatureCompatibilityVersion_common import get_fcv + pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel] def test_smoke_setFeatureCompatibilityVersion(collection): """Test basic setFeatureCompatibilityVersion behavior.""" - get_result = execute_admin_command( - collection, {"getParameter": 1, "featureCompatibilityVersion": 1} - ) - - original_fcv = "8.2" - if not isinstance(get_result, Exception): - fcv_data = get_result.get("featureCompatibilityVersion", {}) - if isinstance(fcv_data, dict): - original_fcv = fcv_data.get("version", "8.2") - else: - original_fcv = str(fcv_data) - - new_fcv = "8.2" if original_fcv != "8.2" else "8.0" + original_fcv = get_fcv(collection) + new_fcv = "8.0" if original_fcv != "8.0" else "8.2" result = execute_admin_command( collection, {"setFeatureCompatibilityVersion": new_fcv, "confirm": True} ) - - expected = {"ok": 1.0} assertSuccessPartial( - result, expected, msg="Should support setFeatureCompatibilityVersion command" + result, + {"ok": 1.0}, + msg="setFeatureCompatibilityVersion should succeed with a valid version change", ) - execute_admin_command( collection, {"setFeatureCompatibilityVersion": original_fcv, "confirm": True} ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/setFeatureCompatibilityVersion_common.py b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/setFeatureCompatibilityVersion_common.py new file mode 100644 index 000000000..efe74a378 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setFeatureCompatibilityVersion/utils/setFeatureCompatibilityVersion_common.py @@ -0,0 +1,16 @@ +"""Utilities for setFeatureCompatibilityVersion tests.""" + +from documentdb_tests.framework.executor import execute_admin_command + + +def get_fcv(collection): + """Read the current FCV via getParameter.""" + result = execute_admin_command( + collection, {"getParameter": 1, "featureCompatibilityVersion": 1} + ) + if isinstance(result, Exception): + return "8.2" + fcv_data = result.get("featureCompatibilityVersion", {}) + if isinstance(fcv_data, dict): + return fcv_data.get("version", "8.2") + return str(fcv_data) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 1456e8ed9..3424676d0 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -65,6 +65,7 @@ CHANGE_STREAM_HISTORY_LOST_ERROR = 286 NO_QUERY_EXECUTION_PLANS_ERROR = 291 QUERY_EXCEEDED_MEMORY_NO_DISK_USE_ERROR = 292 +FEATURE_NOT_SUPPORTED_ERROR = 303 API_VERSION_ERROR = 322 API_STRICT_ERROR = 323 CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR = 359 @@ -457,6 +458,7 @@ MODULO_BY_ZERO_V2_ERROR = 4848403 API_VERSION_REQUIRED_ERROR = 4886600 LET_FIELD_REFERENCE_IN_VALUE_ERROR = 4890500 +FCV_INVALID_VERSION_ERROR = 4926900 COLLECTION_UUID_NOT_SUPPORTED_AGNOSTIC_ERROR = 4928901 ARRAY_TO_OBJECT_NULL_BYTE_PAIR_KEY_ERROR = 4940400 ARRAY_TO_OBJECT_NULL_BYTE_KV_KEY_ERROR = 4940401 @@ -529,6 +531,7 @@ WILDCARD_STRING_TYPE_ERROR = 7246202 OUT_TIMESERIES_COLLECTION_TYPE_ERROR = 7268700 MISSING_COMPACT_TOKEN_ERROR = 7294900 +FCV_CONFIRM_REQUIRED_ERROR = 7369100 OUT_TIMESERIES_OPTIONS_MISMATCH_ERROR = 7406103 SORT_DUPLICATE_KEY_ERROR = 7472500 N_ACCUMULATOR_INVALID_N_ERROR = 7548606 From 159b5a7abca9c61c0e23d338dc648edba70b6123 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" <96995091+alinaliBQ@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:01:21 -0700 Subject: [PATCH 46/51] Add `setUserWriteBlockMode` tests (#658) Signed-off-by: Alina (Xi) Li --- .../setUserWriteBlockMode/__init__.py | 0 ...tUserWriteBlockMode_argument_validation.py | 150 ++++++++++++++ ...est_setUserWriteBlockMode_core_behavior.py | 141 +++++++++++++ .../test_setUserWriteBlockMode_errors.py | 108 ++++++++++ .../test_setUserWriteBlockMode_success.py | 175 ++++++++++++++++ ...rWriteBlockMode_write_block_enforcement.py | 194 ++++++++++++++++++ .../setUserWriteBlockMode/utils/__init__.py | 0 .../utils/write_block_helpers.py | 30 +++ .../administration/commands/utils/__init__.py | 0 .../commands/utils/admin_test_case.py | 49 +++++ documentdb_tests/framework/error_codes.py | 1 + 11 files changed, 848 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_success.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_write_block_enforcement.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/write_block_helpers.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/utils/admin_test_case.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_argument_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_argument_validation.py new file mode 100644 index 000000000..b29c2c161 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_argument_validation.py @@ -0,0 +1,150 @@ +"""Tests for setUserWriteBlockMode argument validation errors. + +Validates type rejection for the global and reason fields, missing required fields, +invalid enum values, and unrecognized fields. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.compatibility.tests.system.administration.commands.utils.admin_test_case import ( # noqa: E501 + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_INFINITY, FLOAT_NAN + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Property [Global Field Type Rejection]: setUserWriteBlockMode rejects all non-boolean types +# for the global field with no coercion. +GLOBAL_TYPE_REJECTION_TESTS: list[AdminTestCase] = [ + AdminTestCase( + f"global_type_{tid}", + command=lambda ctx, v=val: {"setUserWriteBlockMode": 1, "global": v}, + error_code=error, + msg=f"setUserWriteBlockMode should reject {tid} for global field", + ) + for tid, val, error in [ + ("int32_1", 1, TYPE_MISMATCH_ERROR), + ("int32_0", 0, TYPE_MISMATCH_ERROR), + ("double_1", 1.0, TYPE_MISMATCH_ERROR), + ("double_0", 0.0, TYPE_MISMATCH_ERROR), + ("int64", Int64(1), TYPE_MISMATCH_ERROR), + ("decimal128", Decimal128("1"), TYPE_MISMATCH_ERROR), + ("nan", FLOAT_NAN, TYPE_MISMATCH_ERROR), + ("infinity", FLOAT_INFINITY, TYPE_MISMATCH_ERROR), + ("negative_infinity", float("-inf"), TYPE_MISMATCH_ERROR), + ("negative_zero", -0.0, TYPE_MISMATCH_ERROR), + ("string", "true", TYPE_MISMATCH_ERROR), + ("array", [], TYPE_MISMATCH_ERROR), + ("object", {}, TYPE_MISMATCH_ERROR), + ] +] + +# Property [Missing Global Field]: setUserWriteBlockMode requires the global field. +# Null is treated as missing. +MISSING_GLOBAL_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "missing_global", + command=lambda ctx: {"setUserWriteBlockMode": 1}, + error_code=MISSING_FIELD_ERROR, + msg="setUserWriteBlockMode should require the global field", + ), + AdminTestCase( + "global_null_treated_as_missing", + command=lambda ctx: {"setUserWriteBlockMode": 1, "global": None}, + error_code=MISSING_FIELD_ERROR, + msg="setUserWriteBlockMode should treat null global as missing", + ), +] + +# Property [Reason Field Type Rejection]: setUserWriteBlockMode rejects non-string types for +# the reason field. +REASON_TYPE_REJECTION_TESTS: list[AdminTestCase] = [ + AdminTestCase( + f"reason_type_{tid}", + command=lambda ctx, v=val: { + "setUserWriteBlockMode": 1, + "global": True, + "reason": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setUserWriteBlockMode should reject {tid} for reason field", + ) + for tid, val in [ + ("int", 1), + ("bool", True), + ("array", []), + ("object", {}), + ] +] + +# Property [Reason Field Invalid Enum]: setUserWriteBlockMode rejects unrecognized reason +# strings. +REASON_INVALID_ENUM_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "reason_invalid_enum", + command=lambda ctx: { + "setUserWriteBlockMode": 1, + "global": True, + "reason": "InvalidReason", + }, + error_code=BAD_VALUE_ERROR, + msg="setUserWriteBlockMode should reject unrecognized reason enum value", + ), +] + +# Property [Unrecognized Fields]: setUserWriteBlockMode rejects unknown fields. +UNRECOGNIZED_FIELD_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "unrecognized_field", + command=lambda ctx: { + "setUserWriteBlockMode": 1, + "global": False, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setUserWriteBlockMode should reject unrecognized fields", + ), +] + +ARGUMENT_ERROR_TESTS: list[AdminTestCase] = ( + GLOBAL_TYPE_REJECTION_TESTS + + MISSING_GLOBAL_TESTS + + REASON_TYPE_REJECTION_TESTS + + REASON_INVALID_ENUM_TESTS + + UNRECOGNIZED_FIELD_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_ERROR_TESTS)) +def test_setUserWriteBlockMode_argument_error(collection, test): + """Test setUserWriteBlockMode rejects invalid arguments.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_core_behavior.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_core_behavior.py new file mode 100644 index 000000000..9575ddacb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_core_behavior.py @@ -0,0 +1,141 @@ +"""Tests for setUserWriteBlockMode core behavior. + +Validates enable/disable semantics, idempotent behavior, and state restoration. +""" + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Property [Idempotent Disable]: disabling write block when no block is active succeeds and +# writes are allowed. +def test_setUserWriteBlockMode_disable_when_no_block_active(collection): + """Test setUserWriteBlockMode global:false when no block is active leaves writes allowed.""" + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": False}) + result = execute_command( + collection, {"insert": collection.name, "documents": [{"_id": "no_block_write"}]} + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should leave writes allowed when disabling with no active block", + ) + + +# Property [Write Restoration]: writes succeed after disabling a previously active block. +# (Blocking of writes while a block is active is covered by +# test_setUserWriteBlockMode_write_block_enforcement.py.) +def test_setUserWriteBlockMode_enable_disable_restores_writes(collection): + """Test setUserWriteBlockMode enable then disable allows writes again.""" + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": False}) + result = execute_command( + collection, {"insert": collection.name, "documents": [{"_id": "restore_test"}]} + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should allow writes after block is disabled", + ) + + +# Property [Repeated Toggle]: toggling write block multiple times does not produce errors. +def test_setUserWriteBlockMode_toggle_multiple_times(collection): + """Test setUserWriteBlockMode toggling on and off multiple times succeeds.""" + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": False}) + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": False}) + result = execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should succeed after repeated toggling", + ) + + +# Property [Idempotent Enable]: re-enabling with same default reason is idempotent. +def test_setUserWriteBlockMode_enable_idempotent_same_reason(collection): + """Test setUserWriteBlockMode re-enable with same reason is idempotent.""" + execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + result = execute_admin_command(collection, {"setUserWriteBlockMode": 1, "global": True}) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should be idempotent when re-enabling with same reason", + ) + + +# Property [Same Explicit Reason Idempotent]: re-enabling with same explicit reason succeeds. +def test_setUserWriteBlockMode_same_reason_unspecified_idempotent(collection): + """Test setUserWriteBlockMode re-enable with same reason Unspecified is idempotent.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should be idempotent with same explicit reason", + ) + + +def test_setUserWriteBlockMode_same_reason_disk_threshold_idempotent(collection): + """Test setUserWriteBlockMode re-enable with same reason DiskUseThresholdExceeded.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "DiskUseThresholdExceeded"}, + ) + result = execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "DiskUseThresholdExceeded"}, + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should be idempotent with same explicit reason", + ) + + +def test_setUserWriteBlockMode_same_reason_cluster_migration_idempotent(collection): + """Test setUserWriteBlockMode re-enable with same reason ClusterToClusterMigrationInProgress.""" + execute_admin_command( + collection, + { + "setUserWriteBlockMode": 1, + "global": True, + "reason": "ClusterToClusterMigrationInProgress", + }, + ) + result = execute_admin_command( + collection, + { + "setUserWriteBlockMode": 1, + "global": True, + "reason": "ClusterToClusterMigrationInProgress", + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg="setUserWriteBlockMode should be idempotent with same explicit reason", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_errors.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_errors.py new file mode 100644 index 000000000..18eb8cfb6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_errors.py @@ -0,0 +1,108 @@ +"""Tests for setUserWriteBlockMode error cases. + +Validates mismatched reason errors when changing the reason on an active block. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ILLEGAL_OPERATION_ERROR +from documentdb_tests.framework.executor import execute_admin_command + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Property [Mismatched Reason on Enable]: re-enabling with a different reason fails. +def test_setUserWriteBlockMode_enable_mismatched_reason_fails(collection): + """Test setUserWriteBlockMode re-enable with different reason fails.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + { + "setUserWriteBlockMode": 1, + "global": True, + "reason": "ClusterToClusterMigrationInProgress", + }, + ) + assertFailureCode( + result, + ILLEGAL_OPERATION_ERROR, + msg="setUserWriteBlockMode should reject mismatched reason on re-enable", + ) + + +# Property [Mismatched Reason on Disable]: disabling with a different reason than the active +# block fails. +def test_setUserWriteBlockMode_disable_mismatched_reason_fails(collection): + """Test setUserWriteBlockMode disable with different reason fails.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + { + "setUserWriteBlockMode": 1, + "global": False, + "reason": "ClusterToClusterMigrationInProgress", + }, + ) + assertFailureCode( + result, + ILLEGAL_OPERATION_ERROR, + msg="setUserWriteBlockMode should reject mismatched reason on disable", + ) + + +# Property [Mismatched Reason on Enable]: re-enabling with DiskUseThresholdExceeded when a +# different reason is active fails. +def test_setUserWriteBlockMode_enable_mismatched_reason_disk_threshold_fails(collection): + """Test setUserWriteBlockMode re-enable with DiskUseThresholdExceeded over another reason.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "DiskUseThresholdExceeded"}, + ) + assertFailureCode( + result, + ILLEGAL_OPERATION_ERROR, + msg="setUserWriteBlockMode should reject mismatched reason on re-enable", + ) + + +# Property [Mismatched Reason on Disable]: disabling with DiskUseThresholdExceeded when a +# different reason is active fails. +def test_setUserWriteBlockMode_disable_mismatched_reason_disk_threshold_fails(collection): + """Test setUserWriteBlockMode disable with DiskUseThresholdExceeded over another reason.""" + execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": True, "reason": "Unspecified"}, + ) + result = execute_admin_command( + collection, + {"setUserWriteBlockMode": 1, "global": False, "reason": "DiskUseThresholdExceeded"}, + ) + assertFailureCode( + result, + ILLEGAL_OPERATION_ERROR, + msg="setUserWriteBlockMode should reject mismatched reason on disable", + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_success.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_success.py new file mode 100644 index 000000000..ac7526a4b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_success.py @@ -0,0 +1,175 @@ +"""Tests for setUserWriteBlockMode success cases. + +Validates argument acceptance, read operations not blocked while active, +and write operations succeeding when block is disabled. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.compatibility.tests.system.administration.commands.utils.admin_test_case import ( # noqa: E501 + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Global-field boolean acceptance (global:true/false) and valid reason enum acceptance are +# covered by test_setUserWriteBlockMode_core_behavior.py (enable/disable and idempotent-reason +# tests), so they are not duplicated here. + +# Property [Reason Field Optional]: the reason field can be null. reason_null checks the command +# accepts null; reason_null_treated_as_omitted checks null behaves like an omitted reason, proven +# via a write (a no-reason disable only matches, restoring writes, if null was treated as omitted). +REASON_OPTIONAL_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "reason_null", + command=lambda ctx: {"setUserWriteBlockMode": 1, "global": True, "reason": None}, + expected={"ok": 1.0}, + msg="setUserWriteBlockMode should accept null reason", + ), + AdminTestCase( + "reason_null_treated_as_omitted", + use_admin=False, + partial_success=True, + docs=[], + pre_command=lambda c: ( + execute_admin_command(c, {"setUserWriteBlockMode": 1, "global": True, "reason": None}), + execute_admin_command(c, {"setUserWriteBlockMode": 1, "global": False}), + ), + command=lambda ctx: { + "insert": ctx.collection, + "documents": [{"_id": "null_reason_write"}], + }, + expected={"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should treat null reason as omitted so writes resume", + ), +] + +# Property [Read Operations Not Blocked]: read operations succeed while the block is active. +READ_NOT_BLOCKED_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "read_find", + use_admin=False, + partial_success=True, + docs=[{"_id": "read_doc", "x": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"find": ctx.collection, "filter": {"_id": "read_doc"}}, + expected={"ok": 1.0, "cursor": {"firstBatch": [{"_id": "read_doc", "x": 1}]}}, + msg="setUserWriteBlockMode should not block find while active", + ), + AdminTestCase( + "read_aggregate", + use_admin=False, + partial_success=True, + docs=[{"_id": "agg_doc", "x": 5}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"_id": "agg_doc"}}], + "cursor": {}, + }, + expected={"ok": 1.0, "cursor": {"firstBatch": [{"_id": "agg_doc", "x": 5}]}}, + msg="setUserWriteBlockMode should not block aggregate while active", + ), + AdminTestCase( + "read_count", + use_admin=False, + partial_success=True, + docs=[{"_id": "c1"}, {"_id": "c2"}, {"_id": "c3"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"count": ctx.collection}, + expected={"ok": 1.0, "n": 3}, + msg="setUserWriteBlockMode should not block count while active", + ), + AdminTestCase( + "read_distinct", + use_admin=False, + partial_success=True, + docs=[{"_id": "d1", "x": 1}, {"_id": "d2", "x": 2}, {"_id": "d3", "x": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"distinct": ctx.collection, "key": "x"}, + expected={"ok": 1.0, "values": [1, 2]}, + msg="setUserWriteBlockMode should not block distinct while active", + ), +] + +# Property [Writes Succeed When Disabled]: write operations succeed when no block is active. +WRITE_SUCCEEDS_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "write_insert_no_block", + use_admin=False, + partial_success=True, + docs=[], + command=lambda ctx: {"insert": ctx.collection, "documents": [{"_id": "new_doc", "v": 42}]}, + expected={"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should allow insert when block is not active", + ), + AdminTestCase( + "write_update_no_block", + use_admin=False, + partial_success=True, + docs=[{"_id": "target", "x": 1}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [{"q": {"_id": "target"}, "u": {"$set": {"x": 99}}}], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="setUserWriteBlockMode should allow update when block is not active", + ), + AdminTestCase( + "write_delete_no_block", + use_admin=False, + partial_success=True, + docs=[{"_id": "target"}], + command=lambda ctx: { + "delete": ctx.collection, + "deletes": [{"q": {"_id": "target"}, "limit": 1}], + }, + expected={"ok": 1.0, "n": 1}, + msg="setUserWriteBlockMode should allow delete when block is not active", + ), +] + +SUCCESS_TESTS: list[AdminTestCase] = ( + REASON_OPTIONAL_TESTS + READ_NOT_BLOCKED_TESTS + WRITE_SUCCEEDS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SUCCESS_TESTS)) +def test_setUserWriteBlockMode_success(database_client, collection, test): + """Test setUserWriteBlockMode success cases.""" + collection = test.prepare(database_client, collection) + test.run_pre_command(collection) + ctx = CommandContext.from_collection(collection) + if test.use_admin: + result = execute_admin_command(collection, test.build_command(ctx)) + else: + result = execute_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_write_block_enforcement.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_write_block_enforcement.py new file mode 100644 index 000000000..c6fce6074 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/test_setUserWriteBlockMode_write_block_enforcement.py @@ -0,0 +1,194 @@ +"""Tests for setUserWriteBlockMode write block enforcement errors. + +Validates that write operations are rejected while the block is active. +""" + +from __future__ import annotations + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.compatibility.tests.system.administration.commands.setUserWriteBlockMode.utils.write_block_helpers import ( # noqa: E501 + force_disable_write_block, +) +from documentdb_tests.compatibility.tests.system.administration.commands.utils.admin_test_case import ( # noqa: E501 + AdminTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import USER_WRITES_BLOCKED_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.admin, pytest.mark.no_parallel, pytest.mark.requires(cluster_admin=True)] + + +@pytest.fixture(autouse=True) +def _manage_write_block(collection): + """Ensure write block is disabled before and after each test.""" + force_disable_write_block(collection) + yield + force_disable_write_block(collection) + + +# Property [Write Operations Blocked]: all write operations are rejected while the block is +# active. +WRITE_BLOCKED_TESTS: list[AdminTestCase] = [ + AdminTestCase( + "blocked_insert", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + indexes=[IndexModel([("a", 1)], name="a_1")], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"insert": ctx.collection, "documents": [{"_id": "blocked"}]}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block insert while active", + ), + AdminTestCase( + "blocked_update", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + indexes=[IndexModel([("a", 1)], name="a_1")], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "update": ctx.collection, + "updates": [{"q": {}, "u": {"$set": {"x": 2}}}], + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block update while active", + ), + AdminTestCase( + "blocked_delete", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + indexes=[IndexModel([("a", 1)], name="a_1")], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "delete": ctx.collection, + "deletes": [{"q": {}, "limit": 1}], + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block delete while active", + ), + AdminTestCase( + "blocked_findAndModify_update", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "findAndModify": ctx.collection, + "query": {}, + "update": {"$set": {"x": 2}}, + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block findAndModify update while active", + ), + AdminTestCase( + "blocked_findAndModify_remove", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "findAndModify": ctx.collection, + "query": {}, + "remove": True, + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block findAndModify remove while active", + ), + AdminTestCase( + "blocked_createIndexes", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "createIndexes": ctx.collection, + "indexes": [{"key": {"blocked_field": 1}, "name": "blocked_field_1"}], + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block createIndexes while active", + ), + AdminTestCase( + "blocked_dropIndexes", + use_admin=False, + docs=[{"_id": "seed", "a": 1}], + indexes=[IndexModel([("a", 1)], name="a_1")], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"dropIndexes": ctx.collection, "index": "a_1"}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block dropIndexes while active", + ), + AdminTestCase( + "blocked_drop_collection", + use_admin=False, + docs=[{"_id": "seed"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"drop": ctx.collection}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block drop collection while active", + ), + AdminTestCase( + "blocked_create_collection", + use_admin=False, + docs=[{"_id": "seed"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"create": f"{ctx.collection}_blocked_new"}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block create collection while active", + ), + AdminTestCase( + "blocked_dropDatabase", + use_admin=False, + docs=[{"_id": "seed"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: {"dropDatabase": 1}, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block dropDatabase while active", + ), + AdminTestCase( + "blocked_batch_insert", + use_admin=False, + docs=[{"_id": "seed"}], + pre_command=lambda c: execute_admin_command( + c, {"setUserWriteBlockMode": 1, "global": True} + ), + command=lambda ctx: { + "insert": ctx.collection, + "documents": [{"_id": "bulk1"}, {"_id": "bulk2"}], + }, + error_code=USER_WRITES_BLOCKED_ERROR, + msg="setUserWriteBlockMode should block multi-document insert while active", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_BLOCKED_TESTS)) +def test_setUserWriteBlockMode_blocked(database_client, collection, test): + """Test setUserWriteBlockMode blocks write operations while active.""" + collection = test.prepare(database_client, collection) + test.run_pre_command(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/write_block_helpers.py b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/write_block_helpers.py new file mode 100644 index 000000000..e3f442593 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/setUserWriteBlockMode/utils/write_block_helpers.py @@ -0,0 +1,30 @@ +"""Shared utilities for setUserWriteBlockMode tests.""" + + +def force_disable_write_block(collection): + """Force-disable write block regardless of current reason. + + Disabling requires the same ``reason`` that was used to enable the block: + a no-reason disable fails with IllegalOperation ("Cannot release user + writes critical section with different reason than the already-set + reason") when the block was enabled with an explicit reason. So we first + try the no-reason disable (works when enabled with no reason or when + already disabled) and then fall back to each known reason until one + matches the reason the block was enabled with. + """ + admin = collection.database.client.admin + try: + admin.command({"setUserWriteBlockMode": 1, "global": False}) + return + except Exception: + pass + for reason in [ + "Unspecified", + "ClusterToClusterMigrationInProgress", + "DiskUseThresholdExceeded", + ]: + try: + admin.command({"setUserWriteBlockMode": 1, "global": False, "reason": reason}) + return + except Exception: + continue diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/utils/admin_test_case.py b/documentdb_tests/compatibility/tests/system/administration/commands/utils/admin_test_case.py new file mode 100644 index 000000000..d35be87bb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/utils/admin_test_case.py @@ -0,0 +1,49 @@ +"""Shared test case for administration command tests.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) + + +@dataclass(frozen=True) +class AdminTestCase(CommandTestCase): + """Test case for administration command tests. + + Extends CommandTestCase with fields for admin-specific execution: + + Attributes: + use_admin: If True (the default), execute the command against + the admin database via ``execute_admin_command``. If False, + execute against the test database via ``execute_command``. + pre_command: Optional callable ``(collection) -> None`` invoked + after ``prepare`` completes (docs inserted, indexes created) + but before the test command executes. Use this for stateful + setup like enabling a write block. + partial_success: If True, success assertions use partial matching + (only checks that expected keys are present in the result). + Useful for commands that return extra metadata fields. + """ + + use_admin: bool = True + pre_command: Callable[[Collection], Any] | None = None + partial_success: bool = False + + def run_pre_command(self, collection: Collection) -> None: + """Execute the pre_command callable if defined.""" + if self.pre_command is not None: + self.pre_command(collection) + + def build_expected(self, ctx: CommandContext) -> dict[str, Any] | list[dict[str, Any]] | None: + """Resolve expected from a callable or plain value.""" + if self.expected is None or isinstance(self.expected, (dict, list)): + return self.expected + return self.expected(ctx) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 3424676d0..af7608d53 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -70,6 +70,7 @@ API_STRICT_ERROR = 323 CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR = 359 COLLECTION_UUID_MISMATCH_ERROR = 361 +USER_WRITES_BLOCKED_ERROR = 371 QUERYSETTINGS_QUERY_REJECTED_ERROR = 411 EXPRESSION_NOT_OBJECT_ERROR = 10065 BSON_OBJECT_TOO_LARGE_ERROR = 10334 From 68b5982817a28464e6cb9bf26e1afcd9a9d6ade0 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Mon, 6 Jul 2026 19:13:44 -0700 Subject: [PATCH 47/51] Add abs expression tests (#659) Signed-off-by: Daniel Frankcom --- .../arithmetic/abs/test_abs_boundaries.py | 295 ++++++++++++++++++ .../arithmetic/abs/test_abs_errors.py | 92 ++++++ .../arithmetic/abs/test_abs_input_forms.py | 98 ++++++ .../arithmetic/abs/test_abs_magnitude.py | 140 +++++++++ .../arithmetic/abs/test_abs_non_finite.py | 84 +++++ .../arithmetic/abs/test_abs_null.py | 45 +++ .../arithmetic/abs/test_abs_return_type.py | 86 +++++ 7 files changed, 840 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_magnitude.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_return_type.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_boundaries.py new file mode 100644 index 000000000..4975edaf4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_boundaries.py @@ -0,0 +1,295 @@ +"""Tests for $abs at representable-range boundaries, including overflow and underflow.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + DOUBLE_JUST_ABOVE_HALF, + DOUBLE_JUST_BELOW_HALF, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_PRECISION_LOSS, + INT32_MAX, + INT32_MAX_MINUS_1, + INT32_MIN, + INT32_MIN_PLUS_1, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN_PLUS_1, +) + +# Property [Int32 Boundaries]: $abs at int32 limits returns the magnitude, promoting to long when +# the result overflows int32. +ABS_INT32_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"value": INT32_MAX}, + expression={"$abs": ["$value"]}, + expected=INT32_MAX, + msg="$abs should return INT32_MAX for INT32_MAX", + ), + ExpressionTestCase( + "int32_max_minus_1", + doc={"value": INT32_MAX_MINUS_1}, + expression={"$abs": ["$value"]}, + expected=INT32_MAX_MINUS_1, + msg="$abs should return INT32_MAX-1 for INT32_MAX-1", + ), + ExpressionTestCase( + "int32_min_plus_1", + doc={"value": INT32_MIN_PLUS_1}, + expression={"$abs": ["$value"]}, + expected=INT32_MAX, + msg="$abs should return INT32_MAX for INT32_MIN+1", + ), + ExpressionTestCase( + "int32_min", + doc={"value": INT32_MIN}, + expression={"$abs": ["$value"]}, + expected=Int64(INT32_OVERFLOW), + msg="$abs should promote to int64 for INT32_MIN", + ), + ExpressionTestCase( + "int32_overflow", + doc={"value": INT32_OVERFLOW}, + expression={"$abs": ["$value"]}, + expected=Int64(INT32_OVERFLOW), + msg="$abs should return the same long value for INT32_OVERFLOW", + ), + ExpressionTestCase( + "int32_underflow", + doc={"value": INT32_UNDERFLOW}, + expression={"$abs": ["$value"]}, + expected=Int64(-INT32_UNDERFLOW), + msg="$abs should return the negated long value for INT32_UNDERFLOW", + ), +] + +# Property [Int64 Boundaries]: $abs at int64 limits returns the magnitude as a long. +ABS_INT64_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_max", + doc={"value": INT64_MAX}, + expression={"$abs": ["$value"]}, + expected=INT64_MAX, + msg="$abs should return INT64_MAX for INT64_MAX", + ), + ExpressionTestCase( + "int64_max_minus_1", + doc={"value": INT64_MAX_MINUS_1}, + expression={"$abs": ["$value"]}, + expected=INT64_MAX_MINUS_1, + msg="$abs should return INT64_MAX-1 for INT64_MAX-1", + ), + ExpressionTestCase( + "int64_min_plus_1", + doc={"value": INT64_MIN_PLUS_1}, + expression={"$abs": ["$value"]}, + expected=INT64_MAX, + msg="$abs should return INT64_MAX for INT64_MIN+1", + ), +] + +# Property [Double Boundaries]: $abs preserves double magnitude across the representable range, +# for both positive inputs (a no-op) and negative inputs (an actual sign flip). +ABS_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_MIN_SUBNORMAL, + msg="$abs should return the same value for the minimum subnormal double", + ), + ExpressionTestCase( + "double_min_negative_subnormal", + doc={"value": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_MIN_SUBNORMAL, + msg="$abs should return the positive subnormal for the minimum negative subnormal double", + ), + ExpressionTestCase( + "double_near_min", + doc={"value": DOUBLE_NEAR_MIN}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_NEAR_MIN, + msg="$abs should return the same value for a near-min double", + ), + ExpressionTestCase( + "double_near_max", + doc={"value": DOUBLE_NEAR_MAX}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_NEAR_MAX, + msg="$abs should return the same value for a near-max double", + ), + ExpressionTestCase( + "double_max_safe_integer", + doc={"value": float(DOUBLE_MAX_SAFE_INTEGER)}, + expression={"$abs": ["$value"]}, + expected=float(DOUBLE_MAX_SAFE_INTEGER), + msg="$abs should return the same value for the max safe integer double", + ), + ExpressionTestCase( + "double_precision_loss", + doc={"value": float(DOUBLE_PRECISION_LOSS)}, + expression={"$abs": ["$value"]}, + expected=float(DOUBLE_PRECISION_LOSS), + msg="$abs should return the same value for a precision loss double", + ), + ExpressionTestCase( + "double_just_below_half", + doc={"value": DOUBLE_JUST_BELOW_HALF}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_JUST_BELOW_HALF, + msg="$abs should return the same value for a double just below half", + ), + ExpressionTestCase( + "negative_double_just_below_half", + doc={"value": -DOUBLE_JUST_BELOW_HALF}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_JUST_BELOW_HALF, + msg="$abs should return the positive value for a negative double just below half", + ), + ExpressionTestCase( + "double_just_above_half", + doc={"value": DOUBLE_JUST_ABOVE_HALF}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_JUST_ABOVE_HALF, + msg="$abs should return the same value for a double just above half", + ), + ExpressionTestCase( + "negative_double_just_above_half", + doc={"value": -DOUBLE_JUST_ABOVE_HALF}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_JUST_ABOVE_HALF, + msg="$abs should return the positive value for a negative double just above half", + ), +] + +# Property [Decimal128 Boundaries]: $abs preserves decimal128 magnitude and precision, including +# trailing zeros, for both positive inputs (a no-op) and negative inputs (an actual sign flip). +ABS_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_max", + doc={"value": DECIMAL128_MAX}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_MAX, + msg="$abs should return the same value for decimal128 max", + ), + ExpressionTestCase( + "decimal128_min", + doc={"value": DECIMAL128_MIN}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_MAX, + msg="$abs should return decimal128 max for decimal128 min", + ), + ExpressionTestCase( + "decimal128_large_exponent", + doc={"value": DECIMAL128_LARGE_EXPONENT}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_LARGE_EXPONENT, + msg="$abs should return the same value for a decimal128 with a large exponent", + ), + ExpressionTestCase( + "decimal128_small_exponent", + doc={"value": DECIMAL128_SMALL_EXPONENT}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_SMALL_EXPONENT, + msg="$abs should return the same value for a decimal128 with a small exponent", + ), + ExpressionTestCase( + "decimal128_trailing_zero", + doc={"value": DECIMAL128_TRAILING_ZERO}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_TRAILING_ZERO, + msg="$abs should preserve a decimal128 trailing zero", + ), + ExpressionTestCase( + "negative_decimal128_trailing_zero", + doc={"value": Decimal128("-1.0")}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_TRAILING_ZERO, + msg="$abs should preserve a decimal128 trailing zero through negation", + ), + ExpressionTestCase( + "decimal128_many_trailing_zeros", + doc={"value": DECIMAL128_MANY_TRAILING_ZEROS}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_MANY_TRAILING_ZEROS, + msg="$abs should preserve decimal128 many trailing zeros", + ), + ExpressionTestCase( + "negative_decimal128_many_trailing_zeros", + doc={"value": Decimal128("-1.00000000000000000000000000000000")}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_MANY_TRAILING_ZEROS, + msg="$abs should preserve decimal128 many trailing zeros through negation", + ), + ExpressionTestCase( + "decimal128_just_below_half", + doc={"value": DECIMAL128_JUST_BELOW_HALF}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_JUST_BELOW_HALF, + msg="$abs should return the same value for a decimal128 just below half", + ), + ExpressionTestCase( + "negative_decimal128_just_below_half", + doc={"value": Decimal128("-0.4999999999999999999999999999999999")}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_JUST_BELOW_HALF, + msg="$abs should return the positive value for a negative decimal128 just below half", + ), + ExpressionTestCase( + "decimal128_just_above_half", + doc={"value": DECIMAL128_JUST_ABOVE_HALF}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_JUST_ABOVE_HALF, + msg="$abs should return the same value for a decimal128 just above half", + ), + ExpressionTestCase( + "negative_decimal128_just_above_half", + doc={"value": Decimal128("-0.5000000000000000000000000000000001")}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_JUST_ABOVE_HALF, + msg="$abs should return the positive value for a negative decimal128 just above half", + ), +] + +ABS_BOUNDARY_ALL_TESTS = ( + ABS_INT32_BOUNDARY_TESTS + + ABS_INT64_BOUNDARY_TESTS + + ABS_DOUBLE_BOUNDARY_TESTS + + ABS_DECIMAL_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_BOUNDARY_ALL_TESTS)) +def test_abs_boundaries(collection, test_case: ExpressionTestCase): + """Test $abs representable-range boundary cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_errors.py new file mode 100644 index 000000000..68a120c4b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_errors.py @@ -0,0 +1,92 @@ +"""Tests for $abs type, arity, and overflow errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ABS_OVERFLOW_ERROR, + EXPRESSION_TYPE_MISMATCH_ERROR, + NON_NUMERIC_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + INT64_MIN, +) + +# Property [Type Strictness]: $abs rejects non-numeric input types. +ABS_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"type_{tid}", + doc={"value": val}, + expression={"$abs": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg=f"$abs should reject a {tid} input", + ) + for tid, val in [ + ("string", "abc"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("regex", Regex("abc")), + ("binary", Binary(b"data")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ] +] + +# Property [Arity]: $abs requires exactly one argument. +ABS_ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_zero", + doc={}, + expression={"$abs": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$abs should reject zero arguments", + ), + ExpressionTestCase( + "arity_two", + doc={}, + expression={"$abs": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$abs should reject two arguments", + ), +] + +# Property [Overflow]: $abs of the minimum int64 errors because the positive result is not +# representable as a long. +ABS_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_min_overflow", + doc={"value": INT64_MIN}, + expression={"$abs": ["$value"]}, + error_code=ABS_OVERFLOW_ERROR, + msg="$abs should error on overflow for INT64_MIN", + ), +] + +ABS_ERROR_ALL_TESTS = ABS_TYPE_ERROR_TESTS + ABS_ARITY_ERROR_TESTS + ABS_OVERFLOW_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_ERROR_ALL_TESTS)) +def test_abs_errors(collection, test_case: ExpressionTestCase): + """Test $abs type, arity, and overflow error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_input_forms.py new file mode 100644 index 000000000..a6362638e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_input_forms.py @@ -0,0 +1,98 @@ +"""Tests for $abs argument forms, literals, field paths, and nested expression inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import NON_NUMERIC_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Form]: $abs accepts its single argument bare or wrapped in a one-element +# array. +ABS_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "form_array", + doc={"value": -5}, + expression={"$abs": ["$value"]}, + expected=5, + msg="$abs should accept its argument wrapped in a one-element array", + ), + ExpressionTestCase( + "form_bare", + doc={"value": -5}, + expression={"$abs": "$value"}, + expected=5, + msg="$abs should accept its argument without an array wrapper", + ), +] + +# Property [Literal Input]: $abs evaluates an inline literal argument, not only document fields. +ABS_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$abs": [-5]}, + expected=5, + msg="$abs should return the absolute value of an inline literal argument", + ), +] + +# Property [Expression Input]: $abs evaluates a nested expression argument before taking the +# absolute value. +ABS_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_abs", + doc={"value": -4}, + expression={"$abs": {"$abs": "$value"}}, + expected=4, + msg="$abs should evaluate a nested $abs expression argument", + ), +] + +# Property [Field Path Input]: $abs resolves a field path argument. A dotted path into a nested +# object yields the referenced value; a path over an array of objects resolves to an array, which +# $abs rejects as a non-numeric type. +ABS_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": -5}}, + expression={"$abs": "$a.b"}, + expected=5, + msg="$abs should resolve a dotted field path into a nested object", + ), + ExpressionTestCase( + "composite_array_field_path", + doc={"a": [{"b": -1}, {"b": -2}]}, + expression={"$abs": "$a.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$abs should reject a field path that resolves to an array from an array of objects", + ), + ExpressionTestCase( + "array_index_field_path", + doc={"a": [-5, -6]}, + expression={"$abs": "$a.0"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$abs should reject a numeric path component over an array, which resolves non-numeric", + ), +] + +ABS_INPUT_FORM_TESTS = ( + ABS_ARGUMENT_FORM_TESTS + ABS_LITERAL_TESTS + ABS_EXPRESSION_INPUT_TESTS + ABS_FIELD_PATH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_INPUT_FORM_TESTS)) +def test_abs_input_forms(collection, test_case: ExpressionTestCase): + """Test $abs argument form, literal, field path, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_magnitude.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_magnitude.py new file mode 100644 index 000000000..ff795b40a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_magnitude.py @@ -0,0 +1,140 @@ +"""Tests for $abs magnitude across numeric types and signed-zero handling.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT64_ZERO, +) + +# Property [Magnitude]: $abs returns the absolute value of a number, preserving its numeric type. +ABS_MAGNITUDE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_int32", + doc={"value": 1}, + expression={"$abs": ["$value"]}, + expected=1, + msg="$abs should return the same value for a positive int32", + ), + ExpressionTestCase( + "negative_int32", + doc={"value": -1}, + expression={"$abs": ["$value"]}, + expected=1, + msg="$abs should return the positive value for a negative int32", + ), + ExpressionTestCase( + "positive_int64", + doc={"value": Int64(1)}, + expression={"$abs": ["$value"]}, + expected=Int64(1), + msg="$abs should return the same value for a positive int64", + ), + ExpressionTestCase( + "negative_int64", + doc={"value": Int64(-1)}, + expression={"$abs": ["$value"]}, + expected=Int64(1), + msg="$abs should return the positive value for a negative int64", + ), + ExpressionTestCase( + "positive_double", + doc={"value": 1.5}, + expression={"$abs": ["$value"]}, + expected=1.5, + msg="$abs should return the same value for a positive double", + ), + ExpressionTestCase( + "negative_double", + doc={"value": -1.5}, + expression={"$abs": ["$value"]}, + expected=1.5, + msg="$abs should return the positive value for a negative double", + ), + ExpressionTestCase( + "positive_decimal", + doc={"value": Decimal128("1")}, + expression={"$abs": ["$value"]}, + expected=Decimal128("1"), + msg="$abs should return the same value for a positive decimal128", + ), + ExpressionTestCase( + "negative_decimal", + doc={"value": Decimal128("-1")}, + expression={"$abs": ["$value"]}, + expected=Decimal128("1"), + msg="$abs should return the positive value for a negative decimal128", + ), +] + +# Property [Zero]: $abs of positive or negative zero returns positive zero for each numeric type. +ABS_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_int32", + doc={"value": 0}, + expression={"$abs": ["$value"]}, + expected=0, + msg="$abs should return zero for int32 zero", + ), + ExpressionTestCase( + "zero_int64", + doc={"value": INT64_ZERO}, + expression={"$abs": ["$value"]}, + expected=INT64_ZERO, + msg="$abs should return zero for int64 zero", + ), + ExpressionTestCase( + "zero_double", + doc={"value": DOUBLE_ZERO}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$abs should return zero for double zero", + ), + ExpressionTestCase( + "negative_zero_double", + doc={"value": DOUBLE_NEGATIVE_ZERO}, + expression={"$abs": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$abs should return positive zero for negative zero double", + ), + ExpressionTestCase( + "zero_decimal", + doc={"value": DECIMAL128_ZERO}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$abs should return zero for decimal128 zero", + ), + ExpressionTestCase( + "negative_zero_decimal", + doc={"value": DECIMAL128_NEGATIVE_ZERO}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$abs should return positive zero for negative zero decimal128", + ), +] + +ABS_MAGNITUDE_ALL_TESTS = ABS_MAGNITUDE_TESTS + ABS_ZERO_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_MAGNITUDE_ALL_TESTS)) +def test_abs_magnitude(collection, test_case: ExpressionTestCase): + """Test $abs magnitude and signed-zero cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_non_finite.py new file mode 100644 index 000000000..0457880a4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_non_finite.py @@ -0,0 +1,84 @@ +"""Tests for $abs with infinity and NaN inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: $abs of positive or negative infinity returns positive infinity. +ABS_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_infinity", + doc={"value": FLOAT_INFINITY}, + expression={"$abs": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$abs should return float infinity for float infinity", + ), + ExpressionTestCase( + "float_negative_infinity", + doc={"value": FLOAT_NEGATIVE_INFINITY}, + expression={"$abs": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$abs should return positive infinity for float negative infinity", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"value": DECIMAL128_INFINITY}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$abs should return decimal128 infinity for decimal128 infinity", + ), + ExpressionTestCase( + "decimal128_negative_infinity", + doc={"value": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$abs should return decimal128 positive infinity for decimal128 negative infinity", + ), +] + +# Property [NaN]: $abs of NaN returns NaN of the same type. +ABS_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan", + doc={"value": FLOAT_NAN}, + expression={"$abs": ["$value"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$abs should return NaN for float NaN", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"value": DECIMAL128_NAN}, + expression={"$abs": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$abs should return NaN for decimal128 NaN", + ), +] + +ABS_NON_FINITE_TESTS = ABS_INFINITY_TESTS + ABS_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_NON_FINITE_TESTS)) +def test_abs_non_finite(collection, test_case: ExpressionTestCase): + """Test $abs infinity and NaN cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_null.py new file mode 100644 index 000000000..f6ba0500d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_null.py @@ -0,0 +1,45 @@ +"""Tests for $abs null and missing-field propagation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Propagation]: $abs of null or a missing field returns null. +ABS_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None}, + expression={"$abs": ["$value"]}, + expected=None, + msg="$abs should return null for null input", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$abs": [MISSING]}, + expected=None, + msg="$abs should return null for a missing field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_NULL_TESTS)) +def test_abs_null(collection, test_case: ExpressionTestCase): + """Test $abs null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_return_type.py new file mode 100644 index 000000000..7768e1837 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/abs/test_abs_return_type.py @@ -0,0 +1,86 @@ +"""Tests for $abs return type preservation.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $abs preserves the numeric type of its input, for both negative inputs +# (an actual sign flip) and positive inputs (a no-op). +ABS_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_positive_int32", + doc={"value": 5}, + expression={"$type": {"$abs": "$value"}}, + expected="int", + msg="$abs should preserve int32 type for a positive int32", + ), + ExpressionTestCase( + "return_type_negative_int32", + doc={"value": -5}, + expression={"$type": {"$abs": "$value"}}, + expected="int", + msg="$abs should preserve int32 type for a negative int32", + ), + ExpressionTestCase( + "return_type_positive_int64", + doc={"value": Int64(5)}, + expression={"$type": {"$abs": "$value"}}, + expected="long", + msg="$abs should preserve int64 type for a positive int64", + ), + ExpressionTestCase( + "return_type_negative_int64", + doc={"value": Int64(-5)}, + expression={"$type": {"$abs": "$value"}}, + expected="long", + msg="$abs should preserve int64 type for a negative int64", + ), + ExpressionTestCase( + "return_type_positive_double", + doc={"value": 5.0}, + expression={"$type": {"$abs": "$value"}}, + expected="double", + msg="$abs should preserve double type for a positive double", + ), + ExpressionTestCase( + "return_type_negative_double", + doc={"value": -5.0}, + expression={"$type": {"$abs": "$value"}}, + expected="double", + msg="$abs should preserve double type for a negative double", + ), + ExpressionTestCase( + "return_type_positive_decimal", + doc={"value": Decimal128("5")}, + expression={"$type": {"$abs": "$value"}}, + expected="decimal", + msg="$abs should preserve decimal128 type for a positive decimal128", + ), + ExpressionTestCase( + "return_type_negative_decimal", + doc={"value": Decimal128("-5")}, + expression={"$type": {"$abs": "$value"}}, + expected="decimal", + msg="$abs should preserve decimal128 type for a negative decimal128", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ABS_RETURN_TYPE_TESTS)) +def test_abs_return_type(collection, test_case: ExpressionTestCase): + """Test $abs return type preservation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) From 799faa7523c22364baa9d9f761a2bdd6a67f0832 Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:03:05 -0700 Subject: [PATCH 48/51] Added $add and $ceil tests (#660) Signed-off-by: PatersonProjects --- .../expressions/arithmetic/add/__init__.py | 0 .../arithmetic/add/test_add_date.py | 166 +++++++++++ .../arithmetic/add/test_add_errors.py | 216 ++++++++++++++ .../arithmetic/add/test_add_input_forms.py | 49 ++++ .../arithmetic/add/test_add_non_finite.py | 156 +++++++++++ .../arithmetic/add/test_add_null.py | 88 ++++++ .../arithmetic/add/test_add_numeric.py | 263 ++++++++++++++++++ .../arithmetic/add/test_add_overflow.py | 97 +++++++ .../arithmetic/add/test_add_precision.py | 105 +++++++ .../arithmetic/add/test_add_return_type.py | 116 ++++++++ .../expressions/arithmetic/ceil/__init__.py | 0 .../arithmetic/ceil/test_ceil_boundaries.py | 237 ++++++++++++++++ .../arithmetic/ceil/test_ceil_errors.py | 89 ++++++ .../arithmetic/ceil/test_ceil_input_forms.py | 76 +++++ .../arithmetic/ceil/test_ceil_non_finite.py | 87 ++++++ .../arithmetic/ceil/test_ceil_null.py | 45 +++ .../arithmetic/ceil/test_ceil_numeric.py | 162 +++++++++++ .../arithmetic/ceil/test_ceil_return_type.py | 57 ++++ 18 files changed, 2009 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_return_type.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py new file mode 100644 index 000000000..eff8f4af1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py @@ -0,0 +1,166 @@ +"""Tests for $add date arithmetic including numeric offsets, rounding boundaries, operand +position, and sign handling. +""" + +from datetime import datetime, timedelta, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Date Arithmetic]: $add accepts exactly one date operand and one or more numeric +# operands (in milliseconds). The date may appear in any position. +ADD_DATE_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_int32", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 86400000}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, tzinfo=timezone.utc), + msg="$add should add int32 milliseconds to a date", + ), + ExpressionTestCase( + "date_int64", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Int64(86400000)}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, tzinfo=timezone.utc), + msg="$add should add int64 milliseconds to a date", + ), + ExpressionTestCase( + "date_decimal", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Decimal128("1.5")}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 2000, tzinfo=timezone.utc), + msg="$add should round a decimal128 fractional millisecond value when adding to a date", + ), + ExpressionTestCase( + "date_double_round_up", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 3000, tzinfo=timezone.utc), + msg="$add should round up a double fractional millisecond value (.5) when adding to a date", + ), + ExpressionTestCase( + "date_double_truncates", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 4.4}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 4000, tzinfo=timezone.utc), + msg="$add should truncate a double fractional millisecond value (<.5) when adding to a date", # noqa: E501 + ), +] + +# Property [Date Rounding Boundaries]: $add rounds fractional millisecond offsets using +# round-half-away-from-zero. Values with |frac| < 0.5 truncate toward zero; values with +# |frac| >= 0.5 round away from zero. +ADD_DATE_ROUNDING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_double_0_1", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.1}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should truncate 0.1ms and leave the date unchanged", + ), + ExpressionTestCase( + "date_double_0_49", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.49}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should truncate 0.49ms and leave the date unchanged", + ), + ExpressionTestCase( + "date_double_0_51", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.51}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + msg="$add should round up 0.51ms to 1ms when adding to a date", + ), + ExpressionTestCase( + "date_double_0_6", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.6}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + msg="$add should round up 0.6ms to 1ms when adding to a date", + ), + ExpressionTestCase( + "date_double_neg_0_5", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.5}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1), + msg="$add should round -0.5ms away from zero to -1ms when adding to a date", + ), + ExpressionTestCase( + "date_double_neg_0_51", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.51}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1), + msg="$add should round -0.51ms away from zero to -1ms when adding to a date", + ), +] + +# Property [Date Operand Position]: the date operand may appear in any position among the +# operands; only one date is permitted. +ADD_DATE_POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "number_then_date", + doc={"a": 86400000, "b": datetime(2026, 1, 1, tzinfo=timezone.utc)}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, tzinfo=timezone.utc), + msg="$add should add a date when the numeric operand appears before the date", + ), + ExpressionTestCase( + "date_in_middle", + doc={"a": 1, "b": datetime(2026, 1, 1, tzinfo=timezone.utc), "c": 1000}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=datetime(2026, 1, 1, 0, 0, 1, 1000, tzinfo=timezone.utc), + msg="$add should add a date when it appears in the middle of the operand list", + ), +] + +# Property [Date Sign Handling]: adding zero or a negative number of milliseconds to a date +# returns the date unchanged or subtracted. +ADD_DATE_SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_negative", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -86400000}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2025, 12, 31, tzinfo=timezone.utc), + msg="$add should subtract milliseconds from a date when adding a negative number", + ), + ExpressionTestCase( + "date_zero", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should return the same date when adding zero milliseconds", + ), + ExpressionTestCase( + "date_negative_zero", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.0}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should return the same date when adding negative zero", + ), +] + +ADD_DATE_ALL_TESTS = ( + ADD_DATE_NUMERIC_TESTS + ADD_DATE_POSITION_TESTS + ADD_DATE_SIGN_TESTS + ADD_DATE_ROUNDING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_DATE_ALL_TESTS)) +def test_add_date(collection, test_case: ExpressionTestCase): + """Test $add date arithmetic: numeric types, operand position, and sign handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py new file mode 100644 index 000000000..8e319d956 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py @@ -0,0 +1,216 @@ +"""Tests for $add error cases including invalid operand types, multiple dates, and date overflow.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import ( + MORE_THAN_ONE_DATE_ERROR, + OVERFLOW_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + INT64_MAX, +) + +_NUMERIC_DATE_AND_NULL_TYPES = [ + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.DATE, + BsonType.NULL, +] + +# Property [Type Strictness]: $add rejects non-numeric, non-date operand types. +ADD_TYPE_SPEC = BsonTypeTestCase( + id="add_operand", + msg="$add should reject non-numeric, non-date operand", + keyword="$add", + valid_types=_NUMERIC_DATE_AND_NULL_TYPES, + default_error_code=TYPE_MISMATCH_ERROR, +) + +ADD_TYPE_REJECTION_CASES = generate_bson_rejection_test_cases([ADD_TYPE_SPEC]) +_ADD_TYPE_REJECTION_EXPR = {"$add": [1, "$b"]} + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ADD_TYPE_REJECTION_CASES) +def test_add_rejects_invalid_operand_type(collection, bson_type, sample_value, spec): + """Test $add rejects non-numeric, non-date BSON types as operands.""" + result = execute_expression_with_insert( + collection, _ADD_TYPE_REJECTION_EXPR, {"b": sample_value} + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +# Property [Mixed Valid and Invalid]: $add rejects an invalid operand when it appears among +# valid numeric operands. +ADD_MIXED_VALID_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_valid_invalid", + doc={"a": 1, "b": 2, "c": "string"}, + expression={"$add": ["$a", "$b", "$c"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should error when a string appears among numeric operands", + ), +] + +# Property [Single Invalid Operand]: $add rejects a single operand of an invalid type. +ADD_SINGLE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_string", + doc={"a": "string"}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single string operand", + ), + ExpressionTestCase( + "single_boolean", + doc={"a": True}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single boolean operand", + ), + ExpressionTestCase( + "single_array", + doc={"a": [1, 2]}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single array operand", + ), + ExpressionTestCase( + "single_object", + doc={"a": {"x": 1}}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single object operand", + ), +] + +# Property [Multiple Dates]: $add rejects expressions with more than one date operand. +ADD_MULTIPLE_DATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "add_two_identical_dates", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 1, tzinfo=timezone.utc), + }, + expression={"$add": ["$a", "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when adding two identical date operands", + ), + ExpressionTestCase( + "two_different_dates", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, tzinfo=timezone.utc), + }, + expression={"$add": ["$a", "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when adding two different date operands", + ), + ExpressionTestCase( + "two_dates_with_numbers", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, tzinfo=timezone.utc), + }, + expression={"$add": [1, 2, 3, "$a", "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when two dates appear among numeric operands", + ), + ExpressionTestCase( + "dates_separated_by_number", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, tzinfo=timezone.utc), + }, + expression={"$add": ["$a", 1, "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when two dates are separated by a numeric operand", + ), +] + +# Property [Date with Non-Finite]: $add rejects NaN and Infinity as numeric operands when a +# date is also present, since the resulting date would be non-representable. +ADD_DATE_NON_FINITE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_NAN}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and float NaN", + ), + ExpressionTestCase( + "date_infinity", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_INFINITY}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and float infinity", + ), + ExpressionTestCase( + "date_decimal_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_NAN}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and decimal128 NaN", + ), + ExpressionTestCase( + "date_decimal_infinity", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_INFINITY}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and decimal128 infinity", + ), +] + +# Property [Date Overflow]: $add errors when the millisecond offset would push the date result +# beyond the representable date range. +ADD_DATE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_int64_max", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": INT64_MAX}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding INT64_MAX milliseconds to a date overflows the date range", # noqa: E501 + ), +] + +ADD_REMAINING_ERROR_TESTS = ( + ADD_MIXED_VALID_INVALID_TESTS + + ADD_SINGLE_TYPE_ERROR_TESTS + + ADD_MULTIPLE_DATE_TESTS + + ADD_DATE_NON_FINITE_ERROR_TESTS + + ADD_DATE_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_REMAINING_ERROR_TESTS)) +def test_add_errors(collection, test_case: ExpressionTestCase): + """Test $add multiple-date, single-invalid, and date non-finite error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py new file mode 100644 index 000000000..fa2f73df4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py @@ -0,0 +1,49 @@ +"""Tests for $add input forms including nested expressions and mixed literal/field operands.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Expression Input]: $add evaluates a nested expression argument before summing. +ADD_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_add", + doc={"a": 1, "b": 2}, + expression={"$add": [{"$add": ["$a", "$b"]}, 3]}, + expected=6, + msg="$add should evaluate a nested $add expression as an operand", + ), +] + +# Property [Mixed Literal and Field]: $add accepts a mix of field references and inline literals +# in the same operand list. +ADD_MIXED_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_literal_and_field", + doc={"a": 10}, + expression={"$add": ["$a", 5]}, + expected=15, + msg="$add should sum a field reference and an inline literal operand", + ), +] + +ADD_INPUT_FORM_ALL_TESTS = ADD_EXPRESSION_INPUT_TESTS + ADD_MIXED_INPUT_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_INPUT_FORM_ALL_TESTS)) +def test_add_input_forms(collection, test_case: ExpressionTestCase): + """Test $add literal, nested expression, and mixed input form cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py new file mode 100644 index 000000000..958818e98 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py @@ -0,0 +1,156 @@ +"""Tests for $add infinity and NaN propagation.""" + +import math + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: $add propagates infinity according to IEEE 754 rules. +ADD_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity", + doc={"a": FLOAT_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity when adding infinity and a finite number", + ), + ExpressionTestCase( + "negative_infinity", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity when adding negative infinity and a finite number", # noqa: E501 + ), + ExpressionTestCase( + "single_infinity", + doc={"a": FLOAT_INFINITY}, + expression={"$add": ["$a"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity for a single infinity operand", + ), + ExpressionTestCase( + "inf_plus_inf", + doc={"a": FLOAT_INFINITY, "b": FLOAT_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity when adding two positive infinities", + ), + ExpressionTestCase( + "neg_inf_plus_neg_inf", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity when adding two negative infinities", + ), + ExpressionTestCase( + "inf_plus_zero", + doc={"a": FLOAT_INFINITY, "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity when adding infinity and zero", + ), + ExpressionTestCase( + "neg_inf_plus_zero", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity when adding negative infinity and zero", + ), + ExpressionTestCase( + "decimal_infinity", + doc={"a": DECIMAL128_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_INFINITY, + msg="$add should return decimal128 infinity when adding decimal128 infinity and a number", + ), + ExpressionTestCase( + "decimal_negative_infinity", + doc={"a": DECIMAL128_NEGATIVE_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$add should return decimal128 negative infinity when adding decimal128 negative infinity and a number", # noqa: E501 + ), +] + +# Property [NaN]: $add propagates NaN according to IEEE 754 rules. +ADD_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_add_one", + doc={"a": FLOAT_NAN, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding float NaN and a finite number", + ), + ExpressionTestCase( + "inf_minus_inf", + doc={"a": FLOAT_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding float infinity and negative infinity", + ), + ExpressionTestCase( + "nan_plus_nan", + doc={"a": FLOAT_NAN, "b": FLOAT_NAN}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding two float NaN values", + ), + ExpressionTestCase( + "nan_plus_inf", + doc={"a": FLOAT_NAN, "b": FLOAT_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding float NaN and infinity", + ), + ExpressionTestCase( + "decimal_nan", + doc={"a": DECIMAL128_NAN, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$add should return decimal128 NaN when adding decimal128 NaN and a number", + ), + ExpressionTestCase( + "decimal_nan_plus_nan", + doc={"a": DECIMAL128_NAN, "b": DECIMAL128_NAN}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$add should return decimal128 NaN when adding two decimal128 NaN values", + ), + ExpressionTestCase( + "decimal_inf_minus_inf", + doc={"a": DECIMAL128_INFINITY, "b": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$add should return decimal128 NaN when adding decimal128 infinity and negative infinity", # noqa: E501 + ), +] + +ADD_NON_FINITE_ALL_TESTS = ADD_INFINITY_TESTS + ADD_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_NON_FINITE_ALL_TESTS)) +def test_add_non_finite(collection, test_case: ExpressionTestCase): + """Test $add infinity and NaN propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py new file mode 100644 index 000000000..3854d72d8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py @@ -0,0 +1,88 @@ +"""Tests for $add null and missing field propagation.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: $add returns null if any operand is null or refers to a missing +# field. +ADD_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_null", + doc={"a": None}, + expression={"$add": ["$a"]}, + expected=None, + msg="$add should return null for a single null operand", + ), + ExpressionTestCase( + "null_operand", + doc={"a": 1, "b": None}, + expression={"$add": ["$a", "$b"]}, + expected=None, + msg="$add should return null when any operand is null", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$add": [1, MISSING]}, + expected=None, + msg="$add should return null when any operand is a missing field", + ), + ExpressionTestCase( + "null_with_multiple", + doc={"a": 1, "b": 2, "c": None}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=None, + msg="$add should return null when null appears among multiple operands", + ), + ExpressionTestCase( + "null_in_middle", + doc={"a": 1, "b": 2, "c": 3, "e": 5}, + expression={"$add": ["$a", "$b", "$c", None, "$e"]}, + expected=None, + msg="$add should return null when null appears in the middle of operands", + ), + ExpressionTestCase( + "all_null", + doc={"a": None, "b": None}, + expression={"$add": ["$a", "$b"]}, + expected=None, + msg="$add should return null when all operands are null", + ), + ExpressionTestCase( + "all_missing", + doc={}, + expression={"$add": [MISSING, MISSING]}, + expected=None, + msg="$add should return null when all operands are missing fields", + ), + ExpressionTestCase( + "date_and_null", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": None}, + expression={"$add": ["$a", "$b"]}, + expected=None, + msg="$add should return null when a date is paired with a null operand", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_NULL_TESTS)) +def test_add_null(collection, test_case: ExpressionTestCase): + """Test $add null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py new file mode 100644 index 000000000..971b01581 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py @@ -0,0 +1,263 @@ +"""Tests for $add numeric operations including same-type and mixed-type addition, multiple +operands, empty/single operands, and sign handling. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Same-Type Addition]: $add of two values of the same numeric type returns a value of +# that type. +ADD_SAME_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + doc={"a": 1, "b": 2}, + expression={"$add": ["$a", "$b"]}, + expected=3, + msg="$add should add two int32 values", + ), + ExpressionTestCase( + "same_type_int64", + doc={"a": Int64(10), "b": Int64(20)}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(30), + msg="$add should add two int64 values", + ), + ExpressionTestCase( + "same_type_double", + doc={"a": 1.5, "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=4.0, + msg="$add should add two double values", + ), + ExpressionTestCase( + "same_type_decimal", + doc={"a": Decimal128("10.5"), "b": Decimal128("20.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("31.0"), + msg="$add should add two decimal128 values", + ), +] + +# Property [Type Promotion]: $add promotes to the wider type when operands have different numeric +# types. Precedence: decimal128 > double > int64 > int32. +ADD_MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_int64", + doc={"a": 1, "b": Int64(20)}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(21), + msg="$add should return int64 when adding int32 and int64", + ), + ExpressionTestCase( + "int32_double", + doc={"a": 1, "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=3.5, + msg="$add should return double when adding int32 and double", + ), + ExpressionTestCase( + "int32_decimal", + doc={"a": 1, "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("3.5"), + msg="$add should return decimal128 when adding int32 and decimal128", + ), + ExpressionTestCase( + "int64_double", + doc={"a": Int64(10), "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=12.5, + msg="$add should return double when adding int64 and double", + ), + ExpressionTestCase( + "int64_decimal", + doc={"a": Int64(10), "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("12.5"), + msg="$add should return decimal128 when adding int64 and decimal128", + ), + ExpressionTestCase( + "double_decimal", + doc={"a": 1.5, "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(Decimal128("4.00000000000000")), + msg="$add should return decimal128 when adding double and decimal128", + ), + ExpressionTestCase( + "three_mixed_types", + doc={"a": Decimal128("1.5"), "b": 2.5, "c": Int64(3)}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=pytest.approx(Decimal128("7.00000000000000")), + msg="$add should return decimal128 when adding decimal128, double, and int64", + ), +] + +# Property [Multiple Operands]: $add correctly sums three or more operands. +ADD_MULTIPLE_OPERANDS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "multiple_int32", + doc={"a": 1, "b": 2, "c": 3}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=6, + msg="$add should add multiple int32 values", + ), + ExpressionTestCase( + "multiple_int64", + doc={"a": Int64(1), "b": Int64(2), "c": Int64(3)}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=Int64(6), + msg="$add should add multiple int64 values", + ), + ExpressionTestCase( + "multiple_double", + doc={"a": 1.1, "b": 2.2, "c": 3.3}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=pytest.approx(6.6), + msg="$add should add multiple double values", + ), + ExpressionTestCase( + "multiple_decimal", + doc={ + "a": Decimal128("1"), + "b": Decimal128("2"), + "c": Decimal128("3"), + "d": Decimal128("4"), + }, + expression={"$add": ["$a", "$b", "$c", "$d"]}, + expected=Decimal128("10"), + msg="$add should add multiple decimal128 values", + ), + ExpressionTestCase( + "five_operands", + doc={"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}, + expression={"$add": ["$a", "$b", "$c", "$d", "$e"]}, + expected=15, + msg="$add should correctly sum five int32 operands", + ), + ExpressionTestCase( + "ten_operands", + doc={ + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "f": 6, + "g": 7, + "h": 8, + "i": 9, + "j": 10, + }, + expression={"$add": ["$a", "$b", "$c", "$d", "$e", "$f", "$g", "$h", "$i", "$j"]}, + expected=55, + msg="$add should correctly sum ten int32 operands", + ), +] + +# Property [Empty and Single Operand]: $add of zero operands returns 0; single operand returns +# that value unchanged. +ADD_SINGLE_AND_EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty", + doc={}, + expression={"$add": []}, + expected=0, + msg="$add should return 0 for empty operand list", + ), + ExpressionTestCase( + "single_int32", + doc={"a": 5}, + expression={"$add": ["$a"]}, + expected=5, + msg="$add should return the value for a single int32 operand", + ), + ExpressionTestCase( + "single_int64", + doc={"a": Int64(0)}, + expression={"$add": ["$a"]}, + expected=Int64(0), + msg="$add should return the value for a single int64 operand", + ), + ExpressionTestCase( + "single_double", + doc={"a": 0.0}, + expression={"$add": ["$a"]}, + expected=0.0, + msg="$add should return the value for a single double operand", + ), + ExpressionTestCase( + "single_decimal", + doc={"a": Decimal128("0")}, + expression={"$add": ["$a"]}, + expected=Decimal128("0"), + msg="$add should return the value for a single decimal128 operand", + ), +] + +# Property [Sign Handling]: $add handles negative values and zero correctly. +ADD_SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "negative_positive", + doc={"a": -5, "b": 3}, + expression={"$add": ["$a", "$b"]}, + expected=-2, + msg="$add should add a negative and a positive int32 value", + ), + ExpressionTestCase( + "both_negative", + doc={"a": -10, "b": -20}, + expression={"$add": ["$a", "$b"]}, + expected=-30, + msg="$add should add two negative int32 values", + ), + ExpressionTestCase( + "zeros", + doc={"a": 0, "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=0, + msg="$add should return 0 when adding two int32 zeros", + ), + ExpressionTestCase( + "zero_negative_zero", + doc={"a": 0, "b": -0.0}, + expression={"$add": ["$a", "$b"]}, + expected=0.0, + msg="$add should return 0.0 when adding int32 zero and negative zero double", + ), + ExpressionTestCase( + "sum_to_zero", + doc={"a": 1, "b": 0, "c": -1}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=0, + msg="$add should return 0 when operands sum to zero", + ), +] + +ADD_NUMERIC_ALL_TESTS = ( + ADD_SAME_TYPE_TESTS + + ADD_MIXED_TYPE_TESTS + + ADD_MULTIPLE_OPERANDS_TESTS + + ADD_SINGLE_AND_EMPTY_TESTS + + ADD_SIGN_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_NUMERIC_ALL_TESTS)) +def test_add_numeric(collection, test_case: ExpressionTestCase): + """Test $add numeric type combinations, multiple operands, and sign handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py new file mode 100644 index 000000000..2da84306e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py @@ -0,0 +1,97 @@ +"""Tests for $add integer and double overflow and underflow promotion.""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DOUBLE_FROM_INT64_MAX, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MIN, +) + +# Property [Int32 Overflow]: when an int32 result exceeds the int32 range, $add promotes to +# int64. +ADD_INT32_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_overflow", + doc={"a": INT32_MAX, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(INT32_OVERFLOW), + msg="$add should promote to int64 when the int32 result overflows INT32_MAX", + ), + ExpressionTestCase( + "int32_underflow", + doc={"a": INT32_MIN, "b": -1}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(INT32_UNDERFLOW), + msg="$add should promote to int64 when the int32 result underflows INT32_MIN", + ), +] + +# Property [Int64 Overflow]: when an int64 result exceeds the int64 range, $add promotes to +# double. +ADD_INT64_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_overflow", + doc={"a": INT64_MAX, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(DOUBLE_FROM_INT64_MAX), + msg="$add should promote to double when the int64 result overflows INT64_MAX", + ), + ExpressionTestCase( + "int64_underflow", + doc={"a": INT64_MIN, "b": -1}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(-DOUBLE_FROM_INT64_MAX), + msg="$add should promote to double when the int64 result underflows INT64_MIN", + ), +] + +# Property [Double Overflow]: when a double result exceeds the double range, $add returns +# infinity. +ADD_DOUBLE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_overflow", + doc={"a": 1e308, "b": 1e308}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return positive infinity on double overflow", + ), + ExpressionTestCase( + "double_underflow", + doc={"a": -1e308, "b": -1e308}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity on double underflow", + ), +] + +ADD_OVERFLOW_ALL_TESTS = ( + ADD_INT32_OVERFLOW_TESTS + ADD_INT64_OVERFLOW_TESTS + ADD_DOUBLE_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_OVERFLOW_ALL_TESTS)) +def test_add_overflow(collection, test_case: ExpressionTestCase): + """Test $add integer and double overflow and underflow cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py new file mode 100644 index 000000000..6658eb03a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py @@ -0,0 +1,105 @@ +"""Tests for $add decimal128 precision and boundary value handling.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_INFINITY, +) + +# Property [Decimal128 Precision]: $add preserves decimal128 precision, including exact +# representation of values that are inexact in double. +ADD_DECIMAL_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_precision", + doc={"a": Decimal128("1.5"), "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("4.0"), + msg="$add should preserve decimal128 precision", + ), + ExpressionTestCase( + "decimal_precision_small", + doc={"a": Decimal128("0.1"), "b": Decimal128("0.2")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("0.3"), + msg="$add should exactly represent 0.1 + 0.2 with decimal128", + ), + ExpressionTestCase( + "decimal_large_precision", + doc={ + "a": Decimal128("999999999999999999999999999999999"), + "b": Decimal128("1"), + }, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("1000000000000000000000000000000000"), + msg="$add should handle large decimal128 addition with full precision", + ), + ExpressionTestCase( + "decimal_large_negative_precision", + doc={ + "a": Decimal128("-999999999999999999999999999999999"), + "b": Decimal128("-1"), + }, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("-1000000000000000000000000000000000"), + msg="$add should handle large negative decimal128 addition with full precision", + ), +] + +# Property [Decimal128 Boundaries]: $add at decimal128 boundary values promotes to infinity when +# the result overflows, and returns zero when max and min cancel. +ADD_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_max_plus_zero", + doc={"a": DECIMAL128_MAX, "b": Decimal128("0")}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_MAX, + msg="$add should return decimal128 max when adding zero to decimal128 max", + ), + ExpressionTestCase( + "decimal128_max_plus_max", + doc={"a": DECIMAL128_MAX, "b": DECIMAL128_MAX}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_INFINITY, + msg="$add should return decimal128 infinity when adding two decimal128 max values", + ), + ExpressionTestCase( + "decimal128_min_plus_min", + doc={"a": DECIMAL128_MIN, "b": DECIMAL128_MIN}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$add should return decimal128 negative infinity when adding two decimal128 min values", + ), + ExpressionTestCase( + "decimal128_max_plus_min", + doc={"a": DECIMAL128_MAX, "b": DECIMAL128_MIN}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("0E+6111"), + msg="$add should return zero when adding decimal128 max and decimal128 min", + ), +] + +ADD_PRECISION_ALL_TESTS = ADD_DECIMAL_PRECISION_TESTS + ADD_DECIMAL_BOUNDARY_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_PRECISION_ALL_TESTS)) +def test_add_precision(collection, test_case: ExpressionTestCase): + """Test $add decimal128 precision and boundary value cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py new file mode 100644 index 000000000..d681c7fd2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py @@ -0,0 +1,116 @@ +"""Tests for $add return type promotion rules across numeric and date operand combinations.""" + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $add follows numeric type promotion rules to determine the result type. +# Precedence: decimal128 > double > int64 > int32. Date + numeric always returns date. +ADD_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int_int", + doc={"a": 1, "b": 2}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="int", + msg="$add should return int type when adding two int32 values", + ), + ExpressionTestCase( + "return_type_int_long", + doc={"a": 1, "b": Int64(2)}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="long", + msg="$add should return long type when adding int32 and int64", + ), + ExpressionTestCase( + "return_type_int_double", + doc={"a": 1, "b": 2.0}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="double", + msg="$add should return double type when adding int32 and double", + ), + ExpressionTestCase( + "return_type_int_decimal", + doc={"a": 1, "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding int32 and decimal128", + ), + ExpressionTestCase( + "return_type_long_long", + doc={"a": Int64(1), "b": Int64(2)}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="long", + msg="$add should return long type when adding two int64 values", + ), + ExpressionTestCase( + "return_type_long_double", + doc={"a": Int64(1), "b": 2.0}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="double", + msg="$add should return double type when adding int64 and double", + ), + ExpressionTestCase( + "return_type_long_decimal", + doc={"a": Int64(1), "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding int64 and decimal128", + ), + ExpressionTestCase( + "return_type_double_double", + doc={"a": 1.0, "b": 2.0}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="double", + msg="$add should return double type when adding two double values", + ), + ExpressionTestCase( + "return_type_double_decimal", + doc={"a": 1.0, "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding double and decimal128", + ), + ExpressionTestCase( + "return_type_decimal_decimal", + doc={"a": Decimal128("1"), "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding two decimal128 values", + ), + ExpressionTestCase( + "return_type_date_int", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 1000}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="date", + msg="$add should return date type when adding a date and an int32", + ), + ExpressionTestCase( + "return_type_empty", + doc={}, + expression={"$type": {"$add": []}}, + expected="int", + msg="$add should return int type for an empty operand list", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_RETURN_TYPE_TESTS)) +def test_add_return_type(collection, test_case: ExpressionTestCase): + """Test $add return type promotion rules for all numeric type combinations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_boundaries.py new file mode 100644 index 000000000..151f42977 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_boundaries.py @@ -0,0 +1,237 @@ +"""Tests for $ceil at representable-range boundaries for int32, int64, double, and decimal128.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + DOUBLE_JUST_BELOW_HALF, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_PRECISION_LOSS, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, +) + +# Property [Int32 Boundaries]: $ceil of an integer returns the same integer unchanged. +CEIL_INT32_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"value": INT32_MAX}, + expression={"$ceil": ["$value"]}, + expected=INT32_MAX, + msg="$ceil should return INT32_MAX unchanged", + ), + ExpressionTestCase( + "int32_min", + doc={"value": INT32_MIN}, + expression={"$ceil": ["$value"]}, + expected=INT32_MIN, + msg="$ceil should return INT32_MIN unchanged", + ), + ExpressionTestCase( + "int32_overflow", + doc={"value": INT32_OVERFLOW}, + expression={"$ceil": ["$value"]}, + expected=Int64(INT32_OVERFLOW), + msg="$ceil should return INT32_OVERFLOW unchanged as int64", + ), + ExpressionTestCase( + "int32_underflow", + doc={"value": INT32_UNDERFLOW}, + expression={"$ceil": ["$value"]}, + expected=Int64(INT32_UNDERFLOW), + msg="$ceil should return INT32_UNDERFLOW unchanged as int64", + ), +] + +# Property [Int64 Boundaries]: $ceil of an int64 integer returns the same int64 unchanged. +CEIL_INT64_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_max", + doc={"value": INT64_MAX}, + expression={"$ceil": ["$value"]}, + expected=INT64_MAX, + msg="$ceil should return INT64_MAX unchanged", + ), + ExpressionTestCase( + "int64_max_minus_1", + doc={"value": INT64_MAX_MINUS_1}, + expression={"$ceil": ["$value"]}, + expected=INT64_MAX_MINUS_1, + msg="$ceil should return INT64_MAX-1 unchanged", + ), + ExpressionTestCase( + "int64_min", + doc={"value": INT64_MIN}, + expression={"$ceil": ["$value"]}, + expected=INT64_MIN, + msg="$ceil should return INT64_MIN unchanged", + ), + ExpressionTestCase( + "int64_min_plus_1", + doc={"value": INT64_MIN_PLUS_1}, + expression={"$ceil": ["$value"]}, + expected=INT64_MIN_PLUS_1, + msg="$ceil should return INT64_MIN+1 unchanged", + ), +] + +# Property [Double Boundaries]: $ceil rounds double boundary values up to the nearest integer. +CEIL_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL}, + expression={"$ceil": ["$value"]}, + expected=1.0, + msg="$ceil should round the minimum subnormal double up to 1.0", + ), + ExpressionTestCase( + "double_min_negative_subnormal", + doc={"value": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expression={"$ceil": ["$value"]}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="$ceil should round the minimum negative subnormal double up to negative zero", + ), + ExpressionTestCase( + "double_near_min", + doc={"value": DOUBLE_NEAR_MIN}, + expression={"$ceil": ["$value"]}, + expected=1.0, + msg="$ceil should round a near-min double up to 1.0", + ), + ExpressionTestCase( + "double_near_max", + doc={"value": DOUBLE_NEAR_MAX}, + expression={"$ceil": ["$value"]}, + expected=DOUBLE_NEAR_MAX, + msg="$ceil should return the same value for a near-max double", + ), + ExpressionTestCase( + "double_max_safe_integer", + doc={"value": float(DOUBLE_MAX_SAFE_INTEGER)}, + expression={"$ceil": ["$value"]}, + expected=float(DOUBLE_MAX_SAFE_INTEGER), + msg="$ceil should return the max safe integer double unchanged", + ), + ExpressionTestCase( + "double_precision_loss", + doc={"value": float(DOUBLE_PRECISION_LOSS)}, + expression={"$ceil": ["$value"]}, + expected=float(DOUBLE_PRECISION_LOSS), + msg="$ceil should return the precision loss double unchanged", + ), + ExpressionTestCase( + "double_fraction_between_zero_and_one", + doc={"value": DOUBLE_JUST_BELOW_HALF}, + expression={"$ceil": ["$value"]}, + expected=1.0, + msg="$ceil should round a double fraction in (0,1) up to 1.0", + ), +] + +# Property [Decimal128 Boundaries]: $ceil rounds decimal128 boundary values up to the nearest +# integer, returning NaN when the value overflows the representable range. +CEIL_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_max", + doc={"value": DECIMAL128_MAX}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 max (overflow)", + ), + ExpressionTestCase( + "decimal128_min", + doc={"value": DECIMAL128_MIN}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 min (overflow)", + ), + ExpressionTestCase( + "decimal128_large_exponent", + doc={"value": DECIMAL128_LARGE_EXPONENT}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for a decimal128 with a large exponent (overflow)", + ), + ExpressionTestCase( + "decimal128_small_exponent", + doc={"value": DECIMAL128_SMALL_EXPONENT}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 with a small exponent up to 1", + ), + ExpressionTestCase( + "decimal128_trailing_zero", + doc={"value": DECIMAL128_TRAILING_ZERO}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 with a trailing zero up to 1", + ), + ExpressionTestCase( + "decimal128_many_trailing_zeros", + doc={"value": DECIMAL128_MANY_TRAILING_ZEROS}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 with many trailing zeros up to 1", + ), + ExpressionTestCase( + "decimal128_just_below_half", + doc={"value": DECIMAL128_JUST_BELOW_HALF}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 just below half up to 1", + ), + ExpressionTestCase( + "decimal128_just_above_half", + doc={"value": DECIMAL128_JUST_ABOVE_HALF}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should round a decimal128 just above half up to 1", + ), +] + +CEIL_BOUNDARY_ALL_TESTS = ( + CEIL_INT32_BOUNDARY_TESTS + + CEIL_INT64_BOUNDARY_TESTS + + CEIL_DOUBLE_BOUNDARY_TESTS + + CEIL_DECIMAL_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_BOUNDARY_ALL_TESTS)) +def test_ceil_boundaries(collection, test_case: ExpressionTestCase): + """Test $ceil representable-range boundary cases for all numeric types.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_errors.py new file mode 100644 index 000000000..7ce6b9b19 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_errors.py @@ -0,0 +1,89 @@ +"""Tests for $ceil type mismatch and arity error cases.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + NON_NUMERIC_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +_NUMERIC_AND_NULL_TYPES = [ + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, +] + +# Property [Type Strictness]: $ceil rejects all non-numeric, non-null BSON types. +CEIL_TYPE_SPEC = BsonTypeTestCase( + id="ceil_input", + msg="$ceil should reject non-numeric input", + keyword="$ceil", + valid_types=_NUMERIC_AND_NULL_TYPES, + default_error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, +) + +CEIL_TYPE_REJECTION_CASES = generate_bson_rejection_test_cases([CEIL_TYPE_SPEC]) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", CEIL_TYPE_REJECTION_CASES) +def test_ceil_rejects_non_numeric_input(collection, bson_type, sample_value, spec): + """Test $ceil rejects non-numeric BSON types.""" + result = execute_expression_with_insert( + collection, {"$ceil": ["$value"]}, {"value": sample_value} + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +# Property [Array Input]: $ceil rejects array-typed input; it does not apply element-wise. +# Property [Arity]: $ceil requires exactly one argument. +CEIL_ARGUMENT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_field_path", + doc={"a": [{"b": 1.5}]}, + expression={"$ceil": "$a.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$ceil should reject a field path that resolves to an array", + ), + ExpressionTestCase( + "arity_zero", + doc={}, + expression={"$ceil": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$ceil should reject zero arguments", + ), + ExpressionTestCase( + "arity_two", + doc={}, + expression={"$ceil": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$ceil should reject two arguments", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_ARGUMENT_ERROR_TESTS)) +def test_ceil_argument_errors(collection, test_case: ExpressionTestCase): + """Test $ceil rejects invalid argument count and array-typed input.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_input_forms.py new file mode 100644 index 000000000..081deadd6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_input_forms.py @@ -0,0 +1,76 @@ +"""Tests for $ceil argument forms: array-wrapped, bare, literal, and nested expression inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Form]: $ceil accepts its single argument bare or wrapped in a one-element +# array. +CEIL_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "form_array", + doc={"value": 1.5}, + expression={"$ceil": ["$value"]}, + expected=2.0, + msg="$ceil should accept its argument wrapped in a one-element array", + ), + ExpressionTestCase( + "form_bare", + doc={"value": 1.5}, + expression={"$ceil": "$value"}, + expected=2.0, + msg="$ceil should accept its argument without an array wrapper", + ), +] + +# Property [Literal Input]: $ceil evaluates an inline literal argument, not only document fields. +CEIL_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$ceil": [4.1]}, + expected=5.0, + msg="$ceil should return the ceiling of an inline literal argument", + ), +] + +# Property [Expression Input]: $ceil evaluates a nested expression argument before rounding up. +CEIL_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_ceil", + doc={"value": 4.1}, + expression={"$ceil": {"$ceil": "$value"}}, + expected=5.0, + msg="$ceil should evaluate a nested $ceil expression argument", + ), + ExpressionTestCase( + "object_expression_input", + doc={"value": 1.5}, + expression={"$ceil": {"$multiply": ["$value", 1.0]}}, + expected=2.0, + msg="$ceil should accept an object expression as its argument", + ), +] + +CEIL_INPUT_FORM_ALL_TESTS = ( + CEIL_ARGUMENT_FORM_TESTS + CEIL_LITERAL_TESTS + CEIL_EXPRESSION_INPUT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_INPUT_FORM_ALL_TESTS)) +def test_ceil_input_forms(collection, test_case: ExpressionTestCase): + """Test $ceil argument form, literal, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_non_finite.py new file mode 100644 index 000000000..3a8f0c8dc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_non_finite.py @@ -0,0 +1,87 @@ +"""Tests for $ceil with infinity and NaN inputs for double and decimal128 types.""" + +import math + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: $ceil of positive or negative float infinity returns the same infinity. +# $ceil of decimal128 infinity returns NaN (overflow during rounding). +CEIL_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_infinity", + doc={"value": FLOAT_INFINITY}, + expression={"$ceil": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$ceil should return float infinity for float infinity", + ), + ExpressionTestCase( + "float_negative_infinity", + doc={"value": FLOAT_NEGATIVE_INFINITY}, + expression={"$ceil": ["$value"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$ceil should return float negative infinity for float negative infinity", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"value": DECIMAL128_INFINITY}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 infinity", + ), + ExpressionTestCase( + "decimal128_negative_infinity", + doc={"value": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 negative infinity", + ), +] + +# Property [NaN]: $ceil of NaN returns NaN of the same type. +CEIL_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan", + doc={"value": FLOAT_NAN}, + expression={"$ceil": ["$value"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$ceil should return NaN for float NaN", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"value": DECIMAL128_NAN}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$ceil should return NaN for decimal128 NaN", + ), +] + +CEIL_NON_FINITE_TESTS = CEIL_INFINITY_TESTS + CEIL_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_NON_FINITE_TESTS)) +def test_ceil_non_finite(collection, test_case: ExpressionTestCase): + """Test $ceil infinity and NaN cases for double and decimal128.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_null.py new file mode 100644 index 000000000..ef29f5126 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_null.py @@ -0,0 +1,45 @@ +"""Tests for $ceil null and missing field propagation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Propagation]: $ceil of null or a missing field returns null. +CEIL_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None}, + expression={"$ceil": ["$value"]}, + expected=None, + msg="$ceil should return null for null input", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$ceil": [MISSING]}, + expected=None, + msg="$ceil should return null for a missing field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_NULL_TESTS)) +def test_ceil_null(collection, test_case: ExpressionTestCase): + """Test $ceil null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_numeric.py new file mode 100644 index 000000000..58d709142 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_numeric.py @@ -0,0 +1,162 @@ +"""Tests for $ceil basic numeric rounding across int32, int64, double, and decimal128 types.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, +) + +# Property [Integer Identity]: $ceil of any integer value returns the same integer unchanged. +CEIL_INTEGER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_int32", + doc={"value": 1}, + expression={"$ceil": ["$value"]}, + expected=1, + msg="$ceil should return the same value for a positive int32", + ), + ExpressionTestCase( + "negative_int32", + doc={"value": -1}, + expression={"$ceil": ["$value"]}, + expected=-1, + msg="$ceil should return the same value for a negative int32", + ), + ExpressionTestCase( + "zero_int32", + doc={"value": 0}, + expression={"$ceil": ["$value"]}, + expected=0, + msg="$ceil should return zero for int32 zero", + ), + ExpressionTestCase( + "positive_int64", + doc={"value": Int64(1)}, + expression={"$ceil": ["$value"]}, + expected=Int64(1), + msg="$ceil should return the same value for a positive int64", + ), + ExpressionTestCase( + "negative_int64", + doc={"value": Int64(-1)}, + expression={"$ceil": ["$value"]}, + expected=Int64(-1), + msg="$ceil should return the same value for a negative int64", + ), + ExpressionTestCase( + "zero_int64", + doc={"value": Int64(0)}, + expression={"$ceil": ["$value"]}, + expected=Int64(0), + msg="$ceil should return zero for int64 zero", + ), +] + +# Property [Double Rounding]: $ceil rounds a double up to the nearest integer double. +CEIL_DOUBLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_double", + doc={"value": 1.5}, + expression={"$ceil": ["$value"]}, + expected=2.0, + msg="$ceil should round up a positive double to the next integer", + ), + ExpressionTestCase( + "negative_double", + doc={"value": -1.5}, + expression={"$ceil": ["$value"]}, + expected=-1.0, + msg="$ceil should round a negative double toward zero", + ), + ExpressionTestCase( + "zero_double", + doc={"value": 0.0}, + expression={"$ceil": ["$value"]}, + expected=0.0, + msg="$ceil should return zero for double zero", + ), + ExpressionTestCase( + "negative_zero_double", + doc={"value": DOUBLE_NEGATIVE_ZERO}, + expression={"$ceil": ["$value"]}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="$ceil should return zero for negative zero double", + ), + ExpressionTestCase( + "negative_fraction", + doc={"value": -0.5}, + expression={"$ceil": ["$value"]}, + expected=-0.0, + msg="$ceil should round a negative double fraction up to negative zero", + ), +] + +# Property [Decimal128 Rounding]: $ceil rounds a decimal128 up to the nearest integer decimal128. +CEIL_DECIMAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_decimal", + doc={"value": Decimal128("1")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("1"), + msg="$ceil should return the same value for a positive whole decimal128", + ), + ExpressionTestCase( + "negative_decimal", + doc={"value": Decimal128("-1")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("-1"), + msg="$ceil should return the same value for a negative whole decimal128", + ), + ExpressionTestCase( + "zero_decimal", + doc={"value": Decimal128("0")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("0"), + msg="$ceil should return zero for decimal128 zero", + ), + ExpressionTestCase( + "negative_zero_decimal", + doc={"value": DECIMAL128_NEGATIVE_ZERO}, + expression={"$ceil": ["$value"]}, + expected=DECIMAL128_NEGATIVE_ZERO, + msg="$ceil should return negative zero for negative zero decimal128", + ), + ExpressionTestCase( + "positive_decimal_fraction", + doc={"value": Decimal128("1.5")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("2"), + msg="$ceil should round up a positive decimal128 fraction", + ), + ExpressionTestCase( + "negative_decimal_fraction", + doc={"value": Decimal128("-1.5")}, + expression={"$ceil": ["$value"]}, + expected=Decimal128("-1"), + msg="$ceil should round a negative decimal128 fraction toward zero", + ), +] + +CEIL_NUMERIC_ALL_TESTS = CEIL_INTEGER_TESTS + CEIL_DOUBLE_TESTS + CEIL_DECIMAL_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_NUMERIC_ALL_TESTS)) +def test_ceil_numeric(collection, test_case: ExpressionTestCase): + """Test $ceil basic numeric rounding for integer, double, and decimal128 inputs.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_return_type.py new file mode 100644 index 000000000..9d46d3ec6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/ceil/test_ceil_return_type.py @@ -0,0 +1,57 @@ +"""Tests for $ceil return type preservation across numeric types.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $ceil preserves the numeric type of its input. +CEIL_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int32", + doc={"value": 5}, + expression={"$type": {"$ceil": "$value"}}, + expected="int", + msg="$ceil should preserve int32 type", + ), + ExpressionTestCase( + "return_type_int64", + doc={"value": Int64(5)}, + expression={"$type": {"$ceil": "$value"}}, + expected="long", + msg="$ceil should preserve int64 type", + ), + ExpressionTestCase( + "return_type_double", + doc={"value": 1.5}, + expression={"$type": {"$ceil": "$value"}}, + expected="double", + msg="$ceil should preserve double type", + ), + ExpressionTestCase( + "return_type_decimal", + doc={"value": Decimal128("1.5")}, + expression={"$type": {"$ceil": "$value"}}, + expected="decimal", + msg="$ceil should preserve decimal128 type", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(CEIL_RETURN_TYPE_TESTS)) +def test_ceil_return_type(collection, test_case: ExpressionTestCase): + """Test $ceil return type preservation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) From dd0f373b450b2269310e8bd1399d1cb4edfe64ca Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 7 Jul 2026 11:20:33 -0700 Subject: [PATCH 49/51] Add searchMeta stage tests (#638) Signed-off-by: Daniel Frankcom --- .github/workflows/pr-tests.yml | 11 + dev/compose.yaml | 51 +- dev/mongot.yml | 32 ++ .../operator/stages/searchMeta/__init__.py | 0 .../operator/stages/searchMeta/conftest.py | 25 + .../test_searchMeta_collection_states.py | 213 +++++++++ .../searchMeta/test_searchMeta_facets.py | 447 ++++++++++++++++++ .../test_searchMeta_facets_advanced.py | 234 +++++++++ .../searchMeta/test_searchMeta_index.py | 250 ++++++++++ .../searchMeta/test_searchMeta_metadata.py | 288 +++++++++++ .../searchMeta/test_searchMeta_operators.py | 384 +++++++++++++++ .../searchMeta/test_searchMeta_options.py | 90 ++++ .../searchMeta/test_searchMeta_path_forms.py | 145 ++++++ .../test_searchMeta_return_scope.py | 258 ++++++++++ .../test_searchMeta_spec_count_errors.py | 346 ++++++++++++++ ...t_searchMeta_spec_facet_boundary_errors.py | 402 ++++++++++++++++ .../test_searchMeta_spec_facet_errors.py | 414 ++++++++++++++++ .../test_searchMeta_spec_operator_errors.py | 222 +++++++++ .../test_searchMeta_stored_source.py | 171 +++++++ .../test_searchMeta_string_facet.py | 198 ++++++++ .../test_searchMeta_threshold_bounds.py | 113 +++++ .../searchMeta/test_smoke_searchMeta.py | 9 +- .../stages/searchMeta/utils/__init__.py | 0 .../searchMeta/utils/searchMeta_common.py | 110 +++++ .../stages/test_stages_position_searchMeta.py | 300 ++++++++++++ documentdb_tests/framework/engine_registry.py | 37 +- documentdb_tests/framework/preconditions.py | 6 + documentdb_tests/pytest.ini | 1 + 28 files changed, 4747 insertions(+), 10 deletions(-) create mode 100644 dev/mongot.yml create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/conftest.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_collection_states.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets_advanced.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_index.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_metadata.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_operators.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_options.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_path_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_return_scope.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_count_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_boundary_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_operator_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_stored_source.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_string_facet.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_threshold_bounds.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/searchMeta_common.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_searchMeta.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index d4f394227..ec0404df2 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -61,6 +61,17 @@ jobs: --json-report --json-report-file=${{ github.workspace }}/.test-results/${{ matrix.target.name }}-report.json \ --junitxml=${{ github.workspace }}/.test-results/${{ matrix.target.name }}-results.xml + - name: Dump container logs + if: always() + run: | + # One file per service in the profile + mkdir -p "${{ github.workspace }}/.test-results/container-logs" + for svc in $(docker compose -f dev/compose.yaml --profile ${{ matrix.target.profile }} config --services); do + docker compose -f dev/compose.yaml --profile ${{ matrix.target.profile }} \ + logs --no-color --timestamps "$svc" \ + > "${{ github.workspace }}/.test-results/container-logs/${svc}.log" 2>&1 || true + done + - name: Upload test results if: always() uses: actions/upload-artifact@v7 diff --git a/dev/compose.yaml b/dev/compose.yaml index 4258cecfb..60485a08f 100644 --- a/dev/compose.yaml +++ b/dev/compose.yaml @@ -27,7 +27,7 @@ # query: # # A service with no `x-test-target` is not a test target and is ignored by the -# registry. +# registry (e.g. the mongot sidecar, which is reached only through its mongod). # # Memory: each mongod caps its WiredTiger cache (--wiredTigerCacheSizeGB). By # default a mongod sizes its cache to ~50% of the host/VM RAM; with several @@ -60,7 +60,26 @@ services: mongo-replset: image: mongo:8.2.4 profiles: ["mongo-replset", "all"] - command: ["--replSet", "rs0", "--bind_ip_all", "--wiredTigerCacheSizeGB", "1.5"] + command: + - "--replSet" + - "rs0" + - "--bind_ip_all" + - "--wiredTigerCacheSizeGB" + - "1.5" + # Point at the mongot search sidecar so this replica set also serves the + # search surfaces. mongot is transparent to all other behavior, so the + # set behaves identically to a plain replica set apart from gaining + # search; it is one target, not two. + - "--setParameter" + - "mongotHost=mongot:27028" + - "--setParameter" + - "searchIndexManagementHostAndPort=mongot:27028" + - "--setParameter" + - "useGrpcForSearch=true" + - "--setParameter" + - "skipAuthenticationToMongot=true" + - "--setParameter" + - "skipAuthenticationToSearchIndexManagementServer=true" ports: - "27018:27017" healthcheck: @@ -71,3 +90,31 @@ services: x-test-target: engine: mongodb query: directConnection=true + + # mongot: the search sidecar for the mongo-replset target. Not a test target + # on its own; the suite reaches it only through mongo-replset. mongot is + # MongoDB Search Community Edition (SSPL, same license as the server). It + # replicates from the replica set as an authenticated sync source and reads + # its password from a file, so the entrypoint writes that file (a fixed + # local-dev secret, matched by the searchCoordinator user the harness creates + # on the replica set) with owner-only permissions before launching. It retries + # the connection until that user exists. + mongot: + image: mongodb/mongodb-community-search:1.70.1 + profiles: ["mongo-replset", "all"] + entrypoint: + - "sh" + - "-c" + - > + umask 077 && + mkdir -p /mongot-secrets && + printf '%s' "$$MONGOT_SYNC_PASSWORD" > /mongot-secrets/passwordFile && + exec /mongot-community/mongot --config /mongot-config/mongot.yml + environment: + # Fixed local-dev secret shared with the searchCoordinator user the + # harness provisions on mongo-replset. Not a real credential. + MONGOT_SYNC_PASSWORD: "searchSyncPassword" + # Cap mongot's JVM heap. Unset, the JVM sizes its max heap to ~25% of host RAM. + JAVA_TOOL_OPTIONS: "-Xmx1g" + volumes: + - ./mongot.yml:/mongot-config/mongot.yml:ro diff --git a/dev/mongot.yml b/dev/mongot.yml new file mode 100644 index 000000000..82be59cb2 --- /dev/null +++ b/dev/mongot.yml @@ -0,0 +1,32 @@ +# mongot configuration for the mongo-replset target (dev/compose.yaml service +# "mongot"). mongot is MongoDB Search Community Edition (SSPL), the same license +# as the server. It runs alongside the replica set's mongod and serves the +# search and vector search surfaces. +# +# mongot replicates from the mongod replica set as a sync source. It requires an +# authenticated connection (it has no unauthenticated mode), so it logs in as a +# dedicated user holding the searchCoordinator role. That user and its password +# file are provisioned by the target's startup (see dev/compose.yaml). +syncSource: + replicaSet: + hostAndPort: "mongo-replset:27017" + scramAuth: + username: "searchSyncUser" + authSource: "admin" + passwordFile: "/mongot-secrets/passwordFile" + tls: + enabled: false +storage: + dataPath: "/var/lib/mongot" +server: + grpc: + # mongod reaches mongot here (see mongotHost / searchIndexManagementHostAndPort + # on the mongo-replset service). Bound on all interfaces so the mongod + # container can connect over the compose network. + address: "0.0.0.0:27028" + tls: + mode: "disabled" +healthCheck: + address: "0.0.0.0:8080" +logging: + verbosity: INFO diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/conftest.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/conftest.py new file mode 100644 index 000000000..2041e3070 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/conftest.py @@ -0,0 +1,25 @@ +"""Shared fixtures for $searchMeta stage tests.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + open_search_collection, +) + + +@pytest.fixture(scope="package") +def search_collection(engine_client, worker_id) -> Iterator[Collection]: + """Metadata search collection carrying a ``default`` and a non-default + ``alt_idx`` index, for tests that select an index by name. + + Package-scoped and read-only: the search index is built and polled to READY + once for all $searchMeta tests that request it.""" + with open_search_collection( + engine_client, worker_id, "stages_searchmeta_shared::search_collection" + ) as coll: + yield coll diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_collection_states.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_collection_states.py new file mode 100644 index 000000000..6a8975cb0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_collection_states.py @@ -0,0 +1,213 @@ +"""Tests for $searchMeta on empty, unindexed, and nonexistent collections.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + SEARCH_DOCS, + CollectionFixtureTestCase, + build_collection, +) +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 INT64_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def empty_search_collection(engine_client, worker_id) -> Iterator[Collection]: + """Indexed but empty collection.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::empty_search_collection", + "searchmeta_empty", + [], + [{"name": "default", "definition": {"mappings": {"dynamic": True}}}], + ) as coll: + yield coll + + +# Property [Empty Collection Count]: on an indexed but empty collection, +# $searchMeta returns a zero count respecting the requested count.type, +# defaulting to lowerBound. +SEARCHMETA_EMPTY_COLLECTION_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "empty_default", + collection_fixture="empty_search_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + expected=[{"count": {"lowerBound": INT64_ZERO}}], + msg="$searchMeta should default to a lower-bound zero count on an indexed empty " + "collection", + ), + CollectionFixtureTestCase( + "empty_total", + collection_fixture="empty_search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total"}, + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should return a total-zero count on an indexed empty collection when " + "count.type is total", + ), + CollectionFixtureTestCase( + "empty_lower_bound", + collection_fixture="empty_search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[{"count": {"lowerBound": INT64_ZERO}}], + msg="$searchMeta should return a lower-bound-zero count on an indexed empty collection " + "when count.type is lowerBound", + ), +] + + +@pytest.fixture(scope="module") +def no_index_collection(engine_client, worker_id) -> Iterator[Collection]: + """Populated collection with no search index.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::no_index_collection", + "searchmeta_no_index", + SEARCH_DOCS, + None, + ) as coll: + yield coll + + +# Property [No-Index Behavior]: on a populated collection with no search index, +# $searchMeta succeeds and returns a total-zero count that ignores the requested +# count.type, and a facet collector omits the facet field. +SEARCHMETA_NO_INDEX_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "no_index_default", + collection_fixture="no_index_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should return a total-zero count without error on a populated " + "collection that has no search index", + ), + CollectionFixtureTestCase( + "no_index_lower_bound", + collection_fixture="no_index_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should ignore a lowerBound count.type and still key the no-index " + "count as total", + ), + CollectionFixtureTestCase( + "no_index_facet_omits_facet", + collection_fixture="no_index_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + } + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should omit the facet field and return a total-zero count for a facet " + "collector on a collection that has no search index", + ), +] + + +@pytest.fixture(scope="module") +def nonexistent_collection(engine_client, worker_id) -> Iterator[Collection]: + """Handle to a collection that is never created.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::nonexistent_collection", + "searchmeta_nonexistent", + None, + None, + ) as coll: + yield coll + + +# Property [Nonexistent Collection]: on a nonexistent collection, $searchMeta +# returns an empty result with no metadata document, for both an operator and a +# facet collector. +SEARCHMETA_NONEXISTENT_COLLECTION_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "nonexistent_operator", + collection_fixture="nonexistent_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + expected=[], + msg="$searchMeta should return an empty result with no metadata document for an " + "operator on a nonexistent collection", + ), + CollectionFixtureTestCase( + "nonexistent_facet", + collection_fixture="nonexistent_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + } + } + } + ], + expected=[], + msg="$searchMeta should return an empty result with no metadata document for a facet " + "collector on a nonexistent collection", + ), +] + +# All collection-state cases share one execution path; the state is carried as +# data via the fixture each case names. +SEARCHMETA_COLLECTION_STATE_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_EMPTY_COLLECTION_TESTS + + SEARCHMETA_NO_INDEX_TESTS + + SEARCHMETA_NONEXISTENT_COLLECTION_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_COLLECTION_STATE_TESTS)) +def test_searchMeta_collection_state(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta across empty, no-index, and nonexistent collection states.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets.py new file mode 100644 index 000000000..a47264a7c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets.py @@ -0,0 +1,447 @@ +"""Tests for $searchMeta number-facet bucketing result behavior.""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import datetime, timezone + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +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 INT64_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +# Dates spanning two query buckets: ids 1-3 fall in the first interval and ids +# 4-5 in the second, so the asserted bucket counts (3 and 2) are observable. +_DATE_FACET_DOCS = [ + {"_id": 1, "d": datetime(2024, 1, 15, tzinfo=timezone.utc)}, + {"_id": 2, "d": datetime(2024, 2, 15, tzinfo=timezone.utc)}, + {"_id": 3, "d": datetime(2024, 3, 15, tzinfo=timezone.utc)}, + {"_id": 4, "d": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + {"_id": 5, "d": datetime(2024, 9, 15, tzinfo=timezone.utc)}, +] + + +@pytest.fixture(scope="module") +def date_facet_collection(engine_client, worker_id) -> Iterator[Collection]: + """Indexed collection with an explicit date mapping for date faceting. + + The faceted field carries an explicit ``date`` mapping so the index types it + as a date rather than relying on dynamic mapping. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::date_facet_collection", + "searchmeta_date_facet", + _DATE_FACET_DOCS, + [ + { + "name": "default", + "definition": {"mappings": {"dynamic": True, "fields": {"d": [{"type": "date"}]}}}, + } + ], + ) as coll: + yield coll + + +# Property [Facet Collector Envelope]: a facet collector returns a combined +# count sub-document and per-name buckets array; the count defaults to lowerBound +# and the embedded operator is optional (omitting it matches all documents). +SEARCHMETA_FACET_ENVELOPE_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "facet_envelope_with_operator", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "operator": {"text": {"query": "quick", "path": "title"}}, + "facets": { + "nf": {"type": "number", "path": "n", "boundaries": [0, 5, 10, 25]} + }, + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(3)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": 5, "count": Int64(1)}, + {"_id": 10, "count": Int64(1)}, + ] + } + }, + } + ], + msg="$searchMeta facet collector should return a combined count and facet buckets " + "envelope for an embedded operator", + ), + CollectionFixtureTestCase( + "facet_envelope_match_all", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 10, 25]}} + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(2)}, + {"_id": 10, "count": Int64(3)}, + ] + } + }, + } + ], + msg="$searchMeta facet collector should match all documents and default to a " + "lower-bound count when the operator is omitted", + ), +] + +# Property [Facet Number Bucket Boundaries]: each number-facet bucket _id is the +# lower boundary of its range, and float boundaries are preserved as double _id +# values. +SEARCHMETA_FACET_BOUNDARY_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "facet_number_float_boundaries", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0.5, 10.5, 25.5], + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0.5, "count": Int64(3)}, + {"_id": 10.5, "count": Int64(2)}, + ] + } + }, + } + ], + msg="$searchMeta number facet should preserve float boundaries as double bucket _ids", + ), +] + +# Property [Facet Default Overflow Bucket]: a default overflow bucket is emitted +# only when default is set, is always emitted when set (including with zero +# overflow), and its string _id never collides with a numeric boundary _id. +SEARCHMETA_FACET_DEFAULT_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "facet_default_omitted_drops_overflow", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 5]}} + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": {"nf": {"buckets": [{"_id": 0, "count": Int64(1)}]}}, + } + ], + msg="$searchMeta number facet should drop out-of-range values when no default is set", + ), + CollectionFixtureTestCase( + "facet_default_emits_overflow", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 5, 10], + "default": "over", + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": 5, "count": Int64(1)}, + {"_id": "over", "count": Int64(3)}, + ] + } + }, + } + ], + msg="$searchMeta number facet should collect out-of-range values into the default " + "overflow bucket when default is set", + ), + CollectionFixtureTestCase( + "facet_default_zero_overflow", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 100], + "default": "over", + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(5)}, + {"_id": "over", "count": INT64_ZERO}, + ] + } + }, + } + ], + msg="$searchMeta number facet should emit the default overflow bucket with a zero " + "count when no values overflow", + ), + CollectionFixtureTestCase( + "facet_default_string_id_no_collision", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 5], + "default": "0", + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": "0", "count": Int64(4)}, + ] + } + }, + } + ], + msg="$searchMeta number facet should keep a string default _id distinct from a " + "numeric boundary _id of the same digits", + ), +] + +# Property [Facet Zero-Match Buckets]: a zero-match facet query over an indexed +# collection returns the bucket structure with zero counts. +SEARCHMETA_FACET_ZERO_MATCH_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "facet_zero_match", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "operator": {"text": {"query": "nonexistentxyz", "path": "title"}}, + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 2, 25]}}, + } + } + } + ], + expected=[ + { + "count": {"lowerBound": INT64_ZERO}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": INT64_ZERO}, + {"_id": 2, "count": INT64_ZERO}, + ] + } + }, + } + ], + msg="$searchMeta facet collector should return zero-count buckets for a zero-match " + "query", + ), +] + +# Property [Facet Date Bucket Boundaries]: each date-facet bucket _id is its +# range's lower-boundary datetime, and datetimes are bucketed like number facets. +SEARCHMETA_DATE_FACET_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "date_facet_boundaries", + collection_fixture="date_facet_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "df": { + "type": "date", + "path": "d", + "boundaries": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 4, 1, tzinfo=timezone.utc), + datetime(2024, 12, 1, tzinfo=timezone.utc), + ], + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "df": { + "buckets": [ + {"_id": datetime(2024, 1, 1, tzinfo=timezone.utc), "count": Int64(3)}, + {"_id": datetime(2024, 4, 1, tzinfo=timezone.utc), "count": Int64(2)}, + ] + } + }, + } + ], + msg="$searchMeta date facet should bucket datetimes by their lower boundary and echo " + "each bucket _id as the boundary datetime", + ), +] + +# Property [Facet Date Default Overflow Bucket]: a date facet collects datetimes +# outside its boundary ranges into the default overflow bucket when default is +# set, mirroring the number-facet default behavior. +SEARCHMETA_DATE_FACET_DEFAULT_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "date_facet_default_overflow", + collection_fixture="date_facet_collection", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "df": { + "type": "date", + "path": "d", + "boundaries": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 4, 1, tzinfo=timezone.utc), + ], + "default": "over", + } + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "df": { + "buckets": [ + {"_id": datetime(2024, 1, 1, tzinfo=timezone.utc), "count": Int64(3)}, + {"_id": "over", "count": Int64(2)}, + ] + } + }, + } + ], + msg="$searchMeta date facet should collect out-of-range datetimes into the default " + "overflow bucket when default is set", + ), +] + +# Number facets run against the standard search collection; date faceting needs +# an index with an explicit date mapping, so its case names a different fixture. +# Both share one execution path, with the collection carried as data. +SEARCHMETA_FACET_RESULT_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_FACET_ENVELOPE_TESTS + + SEARCHMETA_FACET_BOUNDARY_TESTS + + SEARCHMETA_FACET_DEFAULT_TESTS + + SEARCHMETA_FACET_ZERO_MATCH_TESTS + + SEARCHMETA_DATE_FACET_TESTS + + SEARCHMETA_DATE_FACET_DEFAULT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_FACET_RESULT_TESTS)) +def test_searchMeta_facets(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta number- and date-facet bucket result behavior.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets_advanced.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets_advanced.py new file mode 100644 index 000000000..f5650afd2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_facets_advanced.py @@ -0,0 +1,234 @@ +"""Tests for $searchMeta facet count modifiers, key echo, and multiple facets.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Facet Stage Count Modifier]: a stage-level count modifier changes +# only the count sub-document flavor and leaves the facet result unchanged. +SEARCHMETA_FACET_COUNT_MODIFIER_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_count_total", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + }, + "count": {"type": "total"}, + } + } + ], + expected=[ + { + "count": {"total": Int64(5)}, + "facet": {"nf": {"buckets": [{"_id": 0, "count": Int64(5)}]}}, + } + ], + msg="$searchMeta should apply a stage-level total count modifier on top of the facet " + "result without changing the buckets", + ), + StageTestCase( + "facet_count_lower_bound", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + }, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": {"nf": {"buckets": [{"_id": 0, "count": Int64(5)}]}}, + } + ], + msg="$searchMeta should apply a stage-level lower-bound count modifier on top of the " + "facet result without changing the buckets", + ), +] + +# Property [Facet Key Echo]: facet map keys are not field-path validated and are +# echoed back verbatim as result keys. +SEARCHMETA_FACET_KEY_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_key_{suffix}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {key: {"type": "number", "path": "n", "boundaries": [0, 25]}} + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": {key: {"buckets": [{"_id": 0, "count": Int64(5)}]}}, + } + ], + msg=f"$searchMeta should echo a {suffix} facet key verbatim without field-path " + "validation", + ) + for key, suffix in [ + ("", "empty"), + ("$x", "dollar_prefixed"), + ("a.b", "dotted"), + ] +] + +# Property [Multiple Facets in One Collector]: a collector with multiple named +# facets computes each independently under its own key while sharing one count +# sub-document; a default bucket on one facet does not affect a sibling without +# one. +SEARCHMETA_MULTI_FACET_TESTS: list[StageTestCase] = [ + StageTestCase( + "multi_facet_different_paths", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": {"type": "number", "path": "n", "boundaries": [0, 10, 25]}, + "idf": {"type": "number", "path": "_id", "boundaries": [0, 3, 10]}, + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf": { + "buckets": [ + {"_id": 0, "count": Int64(2)}, + {"_id": 10, "count": Int64(3)}, + ] + }, + "idf": { + "buckets": [ + {"_id": 0, "count": Int64(2)}, + {"_id": 3, "count": Int64(3)}, + ] + }, + }, + } + ], + msg="$searchMeta should compute sibling facets over different paths independently " + "under a single shared count", + ), + StageTestCase( + "multi_facet_same_path_different_boundaries", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf1": {"type": "number", "path": "n", "boundaries": [0, 10, 25]}, + "nf2": {"type": "number", "path": "n", "boundaries": [0, 5, 25]}, + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "nf1": { + "buckets": [ + {"_id": 0, "count": Int64(2)}, + {"_id": 10, "count": Int64(3)}, + ] + }, + "nf2": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": 5, "count": Int64(4)}, + ] + }, + }, + } + ], + msg="$searchMeta should compute sibling facets over the same path with different " + "boundaries independently", + ), + StageTestCase( + "multi_facet_independent_default", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "withd": { + "type": "number", + "path": "n", + "boundaries": [0, 5], + "default": "over", + }, + "nod": {"type": "number", "path": "n", "boundaries": [0, 5]}, + } + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(5)}, + "facet": { + "withd": { + "buckets": [ + {"_id": 0, "count": Int64(1)}, + {"_id": "over", "count": Int64(4)}, + ] + }, + "nod": {"buckets": [{"_id": 0, "count": Int64(1)}]}, + }, + } + ], + msg="$searchMeta should keep a default overflow bucket independent per facet so a " + "sibling without default drops its overflow", + ), +] + +SEARCHMETA_FACET_ADVANCED_TESTS: list[StageTestCase] = ( + SEARCHMETA_FACET_COUNT_MODIFIER_TESTS + + SEARCHMETA_FACET_KEY_TESTS + + SEARCHMETA_MULTI_FACET_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_FACET_ADVANCED_TESTS)) +def test_searchMeta_facets_advanced(search_collection, test_case: StageTestCase): + """Test $searchMeta facet count modifiers, key echo, and multiple facets.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_index.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_index.py new file mode 100644 index 000000000..951babda3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_index.py @@ -0,0 +1,250 @@ +"""Tests for $searchMeta index selection and query modifier behavior.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +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 INT64_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Index Selection and Defaulting]: the index field names the search +# index to use, and omitting it or passing null selects the index named +# "default". +SEARCHMETA_INDEX_DEFAULTING_TESTS: list[StageTestCase] = [ + StageTestCase( + "index_explicit_default", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "default", + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should select the default index when index is the literal " + '"default", matching the omitted-index result', + ), + StageTestCase( + "index_named_non_default", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "alt_idx", + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should select a non-default index when its name matches", + ), + StageTestCase( + "index_null_default", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": None, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should treat a null index as field-absent and use the default index", + ), +] + +# Property [Index Nonexistent Silent Miss]: a well-formed but nonexistent index +# name returns a total-zero count without error; dollar-prefixed strings are +# taken as literal names, and a facet collector omits the facet field on a miss. +SEARCHMETA_INDEX_MISS_TESTS: list[StageTestCase] = [ + StageTestCase( + "index_nonexistent_silent_miss", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "nope", + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should return a total-zero count without error for a " + "nonexistent index name", + ), + StageTestCase( + "index_dollar_field_literal", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "$title", + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should treat a dollar-prefixed index as a literal nonexistent " + "name rather than a field path", + ), + StageTestCase( + "index_double_dollar_literal", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": "$$NOW", + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should treat a double-dollar index as a literal nonexistent " + "name rather than a system variable", + ), + StageTestCase( + "index_facet_nonexistent_omits_facet", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + }, + "index": "nope", + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should omit the facet field and return a total-zero count when " + "a facet collector targets a nonexistent index", + ), +] + +# Property [Index Name Matching and Character Handling]: index name matching is +# exact, case-sensitive, and not whitespace-trimmed; control characters, null +# bytes, Unicode, and long names are valid strings that miss silently with a +# total-zero count. +SEARCHMETA_INDEX_NAME_MATCHING_TESTS: list[StageTestCase] = [ + StageTestCase( + f"index_name_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "index": name, + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg=f"$searchMeta should treat a {suffix} index name as a nonexistent name and " + "miss silently", + ) + for name, suffix in [ + ("Default", "capitalized_default"), + ("default ", "trailing_space_default"), + (" default", "leading_space_default"), + ("def ault", "embedded_space_default"), + ("a\x00b", "embedded_null_byte"), + ("\x00", "single_null_byte"), + # Control characters U+0001, U+0002, U+001F. + ("\x01\x02\x1f", "control_characters"), + ("\t", "tab"), + ("caf\u00e9_\u7d22\u5f15_\U0001f50d", "unicode"), + ("x" * 10_000, "long_name"), + ] +] + +# Property [Modifier Coexistence]: the count and index modifiers coexist with a +# search operator in the same spec without being mistaken for operators. +SEARCHMETA_MODIFIER_COEXISTENCE_TESTS: list[StageTestCase] = [ + StageTestCase( + "coexist_operator_count_index", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total"}, + "index": "default", + } + } + ], + expected=[{"count": {"total": Int64(3)}}], + msg="$searchMeta should accept count and index modifiers alongside a search operator " + "without mistaking them for operators", + ), +] + +# Property [Zero-Match Count]: a query matching no documents on an indexed +# collection returns a zero count respecting the requested count.type, defaulting +# to lowerBound. +SEARCHMETA_ZERO_MATCH_TESTS: list[StageTestCase] = [ + StageTestCase( + "zero_match_default", + pipeline=[{"$searchMeta": {"text": {"query": "nonexistentxyz", "path": "title"}}}], + expected=[{"count": {"lowerBound": INT64_ZERO}}], + msg="$searchMeta should default to a lower-bound zero count for a zero-match query on " + "an indexed collection", + ), + StageTestCase( + "zero_match_total", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "nonexistentxyz", "path": "title"}, + "count": {"type": "total"}, + } + } + ], + expected=[{"count": {"total": INT64_ZERO}}], + msg="$searchMeta should return a total-zero count for a zero-match query when count.type " + "is total", + ), + StageTestCase( + "zero_match_lower_bound", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "nonexistentxyz", "path": "title"}, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[{"count": {"lowerBound": INT64_ZERO}}], + msg="$searchMeta should return a lower-bound-zero count for a zero-match query when " + "count.type is lowerBound", + ), +] + +SEARCHMETA_INDEX_TESTS: list[StageTestCase] = ( + SEARCHMETA_INDEX_DEFAULTING_TESTS + + SEARCHMETA_INDEX_MISS_TESTS + + SEARCHMETA_INDEX_NAME_MATCHING_TESTS + + SEARCHMETA_MODIFIER_COEXISTENCE_TESTS + + SEARCHMETA_ZERO_MATCH_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_INDEX_TESTS)) +def test_searchMeta_index(search_collection, test_case: StageTestCase): + """Test $searchMeta index selection and query modifier behavior.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_metadata.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_metadata.py new file mode 100644 index 000000000..1d3047dd3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_metadata.py @@ -0,0 +1,288 @@ +"""Tests for $searchMeta metadata and count result semantics.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + EXPRESSION_NOT_OBJECT_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.property_checks import Gte, Lte +from documentdb_tests.framework.test_constants import DECIMAL128_ZERO, INT32_MAX + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Count Result Shape and Defaults]: count.type selects the result +# flavor (an exact {count:{total:n}} for total, a {count:{lowerBound:n}} for +# lowerBound), and an empty, null, or type-less count defaults to a +# lower-bound count. +SEARCHMETA_COUNT_SHAPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "count_type_total", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total"}, + } + } + ], + expected=[{"count": {"total": Int64(3)}}], + msg="$searchMeta count.type total should return an exact total count", + ), + StageTestCase( + "count_type_lower_bound", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound"}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta count.type lowerBound should return a lower-bound count", + ), + StageTestCase( + "count_default_empty", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should default to a lower-bound count when count is an empty document", + ), + StageTestCase( + "count_default_null", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": None, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should default to a lower-bound count when count is null", + ), + StageTestCase( + "count_default_type_null", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": None}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should default to a lower-bound count when count.type is null", + ), + StageTestCase( + "count_threshold_no_type", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": 10}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should default to a lower-bound count when a threshold has no type", + ), +] + +# Property [Count Threshold Exactness]: count.type total returns an exact count +# regardless of threshold, and count.type lowerBound returns the exact match +# count whenever the threshold is at least the match count. +SEARCHMETA_THRESHOLD_EXACT_TESTS: list[StageTestCase] = [ + StageTestCase( + "threshold_total_ignores_below_match", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": 1}, + } + } + ], + expected=[{"count": {"total": Int64(3)}}], + msg="$searchMeta count.type total should ignore a threshold below the match count and " + "stay exact", + ), + StageTestCase( + "threshold_lower_bound_exact_when_equal", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 3}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta count.type lowerBound should be exact when threshold equals the " + "match count", + ), +] + +# Property [Count Threshold Type Acceptance]: count.threshold accepts any +# non-negative integer-valued number (int32, in-range int64, whole-number +# double), including the boundaries 0 and int32 max, without error. +SEARCHMETA_THRESHOLD_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "threshold_type_int32_zero", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 0}, + } + } + ], + expected={"count": {"lowerBound": [Gte(0), Lte(3)]}}, + msg="$searchMeta should accept a zero threshold and return a count within the bound", + ), + StageTestCase( + "threshold_type_int32_max", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": INT32_MAX}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept an int32-max threshold boundary", + ), + StageTestCase( + "threshold_type_int64", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": Int64(5)}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept an in-range int64 threshold", + ), + StageTestCase( + "threshold_type_double_whole", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 5.0}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept a whole-number double threshold", + ), +] + +SEARCHMETA_RESULT_TESTS: list[StageTestCase] = ( + SEARCHMETA_COUNT_SHAPE_TESTS + + SEARCHMETA_THRESHOLD_EXACT_TESTS + + SEARCHMETA_THRESHOLD_TYPE_TESTS +) + +# Property [Stage Value Scalar Type Error]: a scalar or null stage value is +# rejected at parse time as not an object. +SEARCHMETA_STAGE_VALUE_SCALAR_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"stage_value_scalar_{tid}", + pipeline=[{"$searchMeta": val}], + error_code=EXPRESSION_NOT_OBJECT_ERROR, + msg=f"$searchMeta should reject a {tid} stage value as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("null", None), + ] +] + +# Property [Stage Value Array Type Error]: an array stage value, including an +# empty array, is rejected at parse time and distinguished from a scalar. +SEARCHMETA_STAGE_VALUE_ARRAY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "stage_value_array_empty", + pipeline=[{"$searchMeta": []}], + error_code=FAILED_TO_PARSE_ERROR, + msg="$searchMeta should reject an empty array stage value as a non-object", + ), + StageTestCase( + "stage_value_array_non_empty", + pipeline=[{"$searchMeta": [{"text": {"query": "quick", "path": "title"}}]}], + error_code=FAILED_TO_PARSE_ERROR, + msg="$searchMeta should reject a non-empty array stage value as a non-object", + ), +] + +SEARCHMETA_STAGE_VALUE_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_STAGE_VALUE_SCALAR_ERROR_TESTS + SEARCHMETA_STAGE_VALUE_ARRAY_ERROR_TESTS +) + +# Result/count semantics and stage-value type errors share one execution path. +SEARCHMETA_TESTS: list[StageTestCase] = SEARCHMETA_RESULT_TESTS + SEARCHMETA_STAGE_VALUE_ERROR_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_TESTS)) +def test_searchMeta_metadata(search_collection, test_case: StageTestCase): + """Test $searchMeta metadata, count result semantics, and stage-value errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_operators.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_operators.py new file mode 100644 index 000000000..aedea2e42 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_operators.py @@ -0,0 +1,384 @@ +"""Tests for $searchMeta recognized-operator compatibility (acceptance matrix).""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DOUBLE_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def autocomplete_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection whose title field is statically indexed as an autocomplete type.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::autocomplete_collection", + "searchmeta_autocomplete", + [ + {"_id": 1, "title": "quick brown fox"}, + {"_id": 2, "title": "quiet night"}, + {"_id": 3, "title": "quick red fox"}, + {"_id": 4, "title": "lazy dog"}, + ], + [ + { + "name": "default", + "definition": { + "mappings": {"dynamic": False, "fields": {"title": {"type": "autocomplete"}}} + }, + } + ], + ) as coll: + yield coll + + +@pytest.fixture(scope="module") +def geo_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection whose loc field is statically indexed as a geo type with shapes.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::geo_collection", + "searchmeta_geo", + [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [10.0, 10.0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [0.1, 0.1]}}, + ], + [ + { + "name": "default", + "definition": { + "mappings": { + "dynamic": False, + "fields": {"loc": {"type": "geo", "indexShapes": True}}, + } + }, + } + ], + ) as coll: + yield coll + + +@pytest.fixture(scope="module") +def embedded_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection whose items array is statically indexed as embeddedDocuments.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::embedded_collection", + "searchmeta_embedded", + [ + {"_id": 1, "items": [{"name": "quick fox"}, {"name": "slow dog"}]}, + {"_id": 2, "items": [{"name": "quick cat"}]}, + {"_id": 3, "items": [{"name": "lazy bird"}]}, + ], + [ + { + "name": "default", + "definition": { + "mappings": { + "dynamic": False, + "fields": {"items": {"type": "embeddedDocuments", "dynamic": True}}, + } + }, + } + ], + ) as coll: + yield coll + + +# Property [Operator Acceptance On A Dynamic Mapping]: each recognized search +# operator that a dynamic mapping can serve is accepted and returns its match +# count as the operator-independent {count:{lowerBound:n}} metadata envelope. +SEARCHMETA_DYNAMIC_OPERATOR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + f"operator_{label}", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {op: spec}}], + expected=[{"count": {"lowerBound": Int64(count)}}], + msg=f"$searchMeta should accept a {label} operator and return its match count as metadata", + ) + for label, op, spec, count in [ + ("text", "text", {"query": "quick", "path": "title"}, 3), + ("phrase", "phrase", {"query": "brown fox", "path": "title"}, 1), + ( + "wildcard", + "wildcard", + {"query": "quick*", "path": "title", "allowAnalyzedField": True}, + 3, + ), + ("exists", "exists", {"path": "title"}, 5), + ("equals", "equals", {"path": "n", "value": 5}, 1), + ("range", "range", {"path": "n", "gte": 5, "lte": 15}, 3), + ("near", "near", {"path": "n", "origin": 10, "pivot": 5}, 5), + ( + "compound", + "compound", + {"must": [{"text": {"query": "quick", "path": "title"}}]}, + 3, + ), + ("in", "in", {"path": "n", "value": [5, 10]}, 2), + ( + "regex", + "regex", + {"query": "quick.*", "path": "title", "allowAnalyzedField": True}, + 3, + ), + ("queryString", "queryString", {"defaultPath": "title", "query": "quick"}, 3), + ("moreLikeThis", "moreLikeThis", {"like": {"title": "quick brown fox"}}, 4), + ("term", "term", {"query": "quick", "path": "title"}, 3), + ( + "span", + "span", + { + "first": { + "operator": {"term": {"query": "quick", "path": "title"}}, + "endPositionLte": 5, + } + }, + 3, + ), + ] +] + +# Property [Operator Acceptance With A Required Index Type]: operators that +# depend on a specific field index type (autocomplete, geo, embeddedDocuments) +# are accepted and return their match count once the field is indexed for them. +SEARCHMETA_REQUIRED_INDEX_OPERATOR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "operator_autocomplete", + collection_fixture="autocomplete_collection", + pipeline=[{"$searchMeta": {"autocomplete": {"query": "qui", "path": "title"}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept an autocomplete operator against an autocomplete-indexed " + "field and return its match count", + ), + CollectionFixtureTestCase( + "operator_geoWithin", + collection_fixture="geo_collection", + pipeline=[ + { + "$searchMeta": { + "geoWithin": { + "path": "loc", + "circle": { + "center": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}, + "radius": 50000, + }, + } + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept a geoWithin operator against a geo-indexed field and " + "return its match count", + ), + CollectionFixtureTestCase( + "operator_geoShape", + collection_fixture="geo_collection", + pipeline=[ + { + "$searchMeta": { + "geoShape": { + "path": "loc", + "relation": "intersects", + "geometry": { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, + } + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept a geoShape operator against a shape-indexed geo field and " + "return its match count", + ), + CollectionFixtureTestCase( + "operator_embeddedDocument", + collection_fixture="embedded_collection", + pipeline=[ + { + "$searchMeta": { + "embeddedDocument": { + "path": "items", + "operator": {"text": {"query": "quick", "path": "items.name"}}, + } + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept an embeddedDocument operator against an " + "embeddedDocuments-indexed field and return its match count", + ), +] + +# Property [Operators Requiring An Absent Index Feature]: operators that depend +# on a specific index type or feature are recognized but rejected at execution +# when that index type or feature is not present on the collection. +SEARCHMETA_UNSUPPORTED_OPERATOR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "operator_autocomplete_no_index", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"autocomplete": {"query": "qui", "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an autocomplete operator when the path is not indexed as " + "an autocomplete field", + ), + CollectionFixtureTestCase( + "operator_geoWithin_no_index", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "geoWithin": { + "path": "loc", + "circle": { + "center": {"type": "Point", "coordinates": [DOUBLE_ZERO, DOUBLE_ZERO]}, + "radius": 50000, + }, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a geoWithin operator when the path is not indexed as a geo " + "field", + ), + CollectionFixtureTestCase( + "operator_geoShape_no_index", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "geoShape": { + "path": "loc", + "relation": "intersects", + "geometry": { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a geoShape operator when the path is not indexed as a " + "shape-enabled geo field", + ), + CollectionFixtureTestCase( + "operator_embeddedDocument_no_index", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "embeddedDocument": { + "path": "items", + "operator": {"text": {"query": "quick", "path": "items.name"}}, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an embeddedDocument operator when the path is not indexed " + "as an embeddedDocuments field", + ), + CollectionFixtureTestCase( + "operator_hasAncestor", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"hasAncestor": {"ancestorPath": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a hasAncestor operator without an indexed document " + "hierarchy", + ), + CollectionFixtureTestCase( + "operator_hasRoot", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "hasRoot": {"operator": {"text": {"query": "quick", "path": "title"}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a hasRoot operator without an indexed document hierarchy", + ), + CollectionFixtureTestCase( + "operator_knnBeta", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"knnBeta": {"path": "title", "vector": [0.1, 0.2], "k": 2}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a knnBeta operator without a vector-indexed field", + ), + CollectionFixtureTestCase( + "operator_vectorSearch", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "vectorSearch": { + "path": "title", + "queryVector": [0.1, 0.2], + "numCandidates": 10, + "limit": 5, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a vectorSearch operator without a vector-indexed field", + ), + CollectionFixtureTestCase( + "operator_search", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"search": {"text": {"query": "quick", "path": "title"}}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a nested search operator", + ), +] + +SEARCHMETA_OPERATOR_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_DYNAMIC_OPERATOR_TESTS + + SEARCHMETA_REQUIRED_INDEX_OPERATOR_TESTS + + SEARCHMETA_UNSUPPORTED_OPERATOR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_OPERATOR_TESTS)) +def test_searchMeta_operator_compatibility( + engine_client, request, test_case: CollectionFixtureTestCase +): + """Test $searchMeta acceptance of every recognized search operator.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_options.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_options.py new file mode 100644 index 000000000..e53482e0c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_options.py @@ -0,0 +1,90 @@ +"""Tests for the $searchMeta concurrent stage option.""" + +from __future__ import annotations + +import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_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_ONE_AND_HALF + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Concurrent Option]: the concurrent option is a recognized boolean +# stage option, so both true and false are accepted with no coercion and the +# metadata count is still returned. +SEARCHMETA_CONCURRENT_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + f"concurrent_{label}", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}, "concurrent": val}} + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg=f"$searchMeta should accept a {label} concurrent option and still return its count", + ) + for label, val in [("true", True), ("false", False)] +] + +# Property [Concurrent Option Type]: the concurrent option must be a boolean, so +# a value of any non-boolean BSON type is rejected with no coercion. A null +# concurrent is treated as the default, so it is excluded. +SEARCHMETA_CONCURRENT_OPTION_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"concurrent_type_{tid}", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}, "concurrent": val}} + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} concurrent option as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +SEARCHMETA_OPTION_TESTS: list[StageTestCase] = ( + SEARCHMETA_CONCURRENT_OPTION_TESTS + SEARCHMETA_CONCURRENT_OPTION_TYPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_OPTION_TESTS)) +def test_searchMeta_options(search_collection, test_case: StageTestCase): + """Test $searchMeta concurrent stage option.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_path_forms.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_path_forms.py new file mode 100644 index 000000000..b0ad04ca6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_path_forms.py @@ -0,0 +1,145 @@ +"""Tests for $searchMeta operator path-construction forms and multi-analyzer paths.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def multi_analyzer_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection whose title field carries a keyword multi-analyzer beside the default.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::multi_analyzer_collection", + "searchmeta_multi_analyzer", + [ + {"_id": 1, "title": "the quick brown fox"}, + {"_id": 2, "title": "quick red fox"}, + {"_id": 3, "title": "quick"}, + {"_id": 4, "title": "slow green turtle"}, + ], + [ + { + "name": "default", + "definition": { + "mappings": { + "dynamic": False, + "fields": { + "title": { + "type": "string", + "analyzer": "lucene.standard", + "multi": {"kw": {"type": "string", "analyzer": "lucene.keyword"}}, + } + }, + } + }, + } + ], + ) as coll: + yield coll + + +# Property [Operator Path Forms]: the embedded operator path accepts an array of +# field paths, a {value} document, and a {wildcard} document, each resolving to +# its covered field(s) and returning the match count as metadata. +SEARCHMETA_PATH_FORM_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "path_array", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": ["title", "cat"]}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept an array of paths and return the match count across them", + ), + CollectionFixtureTestCase( + "path_value_document", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": {"value": "title"}}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept a {value} path document and return the match count", + ), + CollectionFixtureTestCase( + "path_wildcard_document", + collection_fixture="search_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": {"wildcard": "*"}}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept a {wildcard} path document spanning the covered fields", + ), + CollectionFixtureTestCase( + "path_multi_absent_analyzer", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": {"value": "title", "multi": "nope"}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a {value, multi} path referencing an undefined " + "multi-analyzer", + ), +] + +# Property [Operator Path Multi-Analyzer Selection]: a {value, multi} path +# resolves through the named alternate analyzer, so its match count differs from +# the field's default analyzer for the same query. +SEARCHMETA_PATH_MULTI_ANALYZER_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "path_default_analyzer", + collection_fixture="multi_analyzer_collection", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should count every standard-analyzed title containing the term", + ), + CollectionFixtureTestCase( + "path_multi_keyword_analyzer", + collection_fixture="multi_analyzer_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": {"value": "title", "multi": "kw"}}}} + ], + expected=[{"count": {"lowerBound": Int64(1)}}], + msg="$searchMeta should count only the keyword-analyzed title equal to the term when the " + "multi keyword analyzer is selected", + ), +] + +SEARCHMETA_PATH_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_PATH_FORM_TESTS + SEARCHMETA_PATH_MULTI_ANALYZER_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_PATH_TESTS)) +def test_searchMeta_path_forms(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta operator path forms and multi-analyzer path selection.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_return_scope.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_return_scope.py new file mode 100644 index 000000000..30a6eeb0f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_return_scope.py @@ -0,0 +1,258 @@ +"""Tests for the $searchMeta returnScope option (validation, semantics, success).""" + +from __future__ import annotations + +import datetime +from collections.abc import Iterator + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_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_ONE_AND_HALF + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def nested_collection(engine_client, worker_id) -> Iterator[Collection]: + """Collection with nested embeddedDocuments and a stored source on the parent. + + returnScope requires the scope path to be indexed as an embeddedDocuments + field carrying a non-empty storedSource, so the parent ``groups`` field sets + ``storedSource`` and nests the ``tags`` embeddedDocuments the operator targets. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::nested_collection", + "searchmeta_return_scope", + [ + {"_id": 1, "groups": [{"tags": [{"name": "quick"}, {"name": "slow"}]}]}, + {"_id": 2, "groups": [{"tags": [{"name": "quick"}]}]}, + {"_id": 3, "groups": [{"tags": [{"name": "lazy"}]}]}, + ], + [ + { + "name": "default", + "definition": { + "mappings": { + "dynamic": False, + "fields": { + "groups": { + "type": "embeddedDocuments", + "storedSource": True, + "fields": {"tags": {"type": "embeddedDocuments", "dynamic": True}}, + } + }, + } + }, + } + ], + ) as coll: + yield coll + + +# Property [ReturnScope Type]: the returnScope option must be a document, so a +# value of any non-document BSON type is rejected. A null returnScope is treated +# as the default, so it is excluded. +SEARCHMETA_RETURN_SCOPE_TYPE_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + f"return_scope_type_{tid}", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}, "returnScope": val}} + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} returnScope option as a non-document", + ) + for tid, val in [ + ("string", "title"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("array", [{"path": "title"}]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [ReturnScope Path Required]: a returnScope document must carry a path +# field, so a document missing it is rejected. +SEARCHMETA_RETURN_SCOPE_PATH_REQUIRED_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_scope_empty", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}, "returnScope": {}}} + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a returnScope document with no path field", + ), + CollectionFixtureTestCase( + "return_scope_other_field", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnScope": {"other": "title"}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a returnScope document that omits the required path field", + ), +] + +# Property [ReturnScope Path Type]: the returnScope path field must be a string, +# so a non-string path is rejected. +SEARCHMETA_RETURN_SCOPE_PATH_TYPE_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + f"return_scope_path_type_{tid}", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnScope": {"path": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} returnScope path as a non-string", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("bool", True), + ("object", {"a": 1}), + ("array", ["title"]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [ReturnScope Scope Path Index Requirement]: a syntactically valid +# returnScope is rejected when its path is not indexed as an embeddedDocuments +# field carrying a non-empty stored source. +SEARCHMETA_RETURN_SCOPE_INDEX_REQUIREMENT_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_scope_path_not_embedded", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnScope": {"path": "title"}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a returnScope whose path is not indexed as an " + "embeddedDocuments field with a stored source", + ), +] + +# Property [ReturnScope Operator Path Containment]: the operator path must be a +# descendant of returnScope.path, so a returnScope path equal to the operator +# path is rejected. +SEARCHMETA_RETURN_SCOPE_CONTAINMENT_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_scope_path_not_ancestor", + collection_fixture="nested_collection", + pipeline=[ + { + "$searchMeta": { + "embeddedDocument": { + "path": "groups.tags", + "operator": {"text": {"query": "quick", "path": "groups.tags.name"}}, + }, + "returnScope": {"path": "groups.tags"}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a returnScope path that is not a strict ancestor of the " + "operator path", + ), +] + +# Property [ReturnScope Acceptance]: a returnScope whose path is an ancestor of +# the operator path on an embeddedDocuments field with a stored source is +# accepted, and because a metadata stage returns no documents the count envelope +# is returned unchanged. +SEARCHMETA_RETURN_SCOPE_SUCCESS_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_scope_ancestor_path", + collection_fixture="nested_collection", + pipeline=[ + { + "$searchMeta": { + "embeddedDocument": { + "path": "groups.tags", + "operator": {"text": {"query": "quick", "path": "groups.tags.name"}}, + }, + "returnScope": {"path": "groups"}, + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept a returnScope ancestor path and still return only the " + "match count", + ), +] + +SEARCHMETA_RETURN_SCOPE_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_RETURN_SCOPE_TYPE_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_PATH_REQUIRED_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_PATH_TYPE_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_INDEX_REQUIREMENT_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_CONTAINMENT_ERROR_TESTS + + SEARCHMETA_RETURN_SCOPE_SUCCESS_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_RETURN_SCOPE_TESTS)) +def test_searchMeta_return_scope(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta returnScope validation, scope-path semantics, and acceptance.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_count_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_count_errors.py new file mode 100644 index 000000000..0f9b6d021 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_count_errors.py @@ -0,0 +1,346 @@ +"""Tests for $searchMeta count specification errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_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, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_OVERFLOW, + INT32_UNDERFLOW, +) + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Count Not A Document]: a present count value that is not a document +# is rejected. +SEARCHMETA_COUNT_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_not_document_{tid}", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}, "count": val}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} count value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Count Type Not A String]: a count.type value that is not a string is +# rejected. +SEARCHMETA_COUNT_TYPE_NOT_STRING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_type_not_string_{tid}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} count.type value as not a string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Count Type Unknown String]: a count.type string outside +# {lowerBound, total} is rejected, with matching being case-sensitive and not +# whitespace-trimmed. +SEARCHMETA_COUNT_TYPE_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_type_value_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": value}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.type string as an unknown count type", + ) + for value, suffix in [ + ("Total", "capitalized_total"), + ("total ", "trailing_space_total"), + ("foo", "foo"), + ] +] + +# Property [Count Threshold Non-Integer Type]: a count.threshold of a +# non-integer-valued type is rejected, including decimal128 even when its value +# is whole. +SEARCHMETA_COUNT_THRESHOLD_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} count.threshold value as not an integer", + ) + for tid, val in [ + ("string", "x"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("decimal128_whole", Decimal128("2")), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Count Threshold Non-Integral Double]: a count.threshold that is a +# fractional double or NaN is rejected. +SEARCHMETA_COUNT_THRESHOLD_FRACTIONAL_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_fractional_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.threshold double as non-integral", + ) + for val, suffix in [ + (2.5, "fractional"), + (FLOAT_NAN, "nan"), + ] +] + +# Property [Count Threshold Negative]: a negative count.threshold is rejected. +SEARCHMETA_COUNT_THRESHOLD_NEGATIVE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_negative_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.threshold as negative", + ) + for val, suffix in [ + (-1, "minus_one"), + (-2.0, "negative_double"), + ] +] + +# Property [Count Threshold Overflow]: a count.threshold above int32 max is +# rejected. +SEARCHMETA_COUNT_THRESHOLD_OVERFLOW_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_overflow_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.threshold above int32 max", + ) + for val, suffix in [ + (INT32_OVERFLOW, "int32_max_plus_one"), + (FLOAT_INFINITY, "infinity"), + ] +] + +# Property [Count Threshold Underflow]: a count.threshold below int32 min is +# rejected. +SEARCHMETA_COUNT_THRESHOLD_UNDERFLOW_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_threshold_underflow_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"threshold": val}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} count.threshold below int32 min", + ) + for val, suffix in [ + (INT32_UNDERFLOW, "int32_min_minus_one"), + (FLOAT_NEGATIVE_INFINITY, "negative_infinity"), + ] +] + +# Property [Count Threshold Validated For Total]: count.threshold is validated +# for integrality, sign, and range even when count.type is total, where its +# value is otherwise ignored. +SEARCHMETA_COUNT_THRESHOLD_TOTAL_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "count_threshold_total_fractional", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": 2.5}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should validate count.threshold integrality even when count.type is " + "total", + ), + StageTestCase( + "count_threshold_total_negative", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": -1}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should validate count.threshold sign even when count.type is total", + ), + StageTestCase( + "count_threshold_total_overflow", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": {"type": "total", "threshold": INT32_OVERFLOW}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should validate count.threshold range even when count.type is total", + ), +] + +# Property [Count Unrecognized Sub-Field]: an unrecognized sub-field of count is +# rejected, with matching being case-sensitive. +SEARCHMETA_COUNT_UNKNOWN_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"count_unknown_field_{suffix}", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "count": count_value, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject an unrecognized count sub-field {name!r}", + ) + for count_value, name, suffix in [ + ({"type": "total", "foo": 1}, "foo", "alongside_type"), + ({"bar": 1}, "bar", "solo"), + ({"Type": "total"}, "Type", "capitalized_type"), + ] +] + +SEARCHMETA_SPEC_COUNT_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_COUNT_TYPE_ERROR_TESTS + + SEARCHMETA_COUNT_TYPE_NOT_STRING_ERROR_TESTS + + SEARCHMETA_COUNT_TYPE_VALUE_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_TYPE_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_FRACTIONAL_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_NEGATIVE_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_OVERFLOW_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_UNDERFLOW_ERROR_TESTS + + SEARCHMETA_COUNT_THRESHOLD_TOTAL_ERROR_TESTS + + SEARCHMETA_COUNT_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_SPEC_COUNT_ERROR_TESTS)) +def test_searchMeta_spec_count_errors(search_collection, test_case: StageTestCase): + """Test $searchMeta count specification errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_boundary_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_boundary_errors.py new file mode 100644 index 000000000..73fbc3c86 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_boundary_errors.py @@ -0,0 +1,402 @@ +"""Tests for $searchMeta facet boundary, numBuckets, and token-mapping errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Facet Number Boundaries Validation]: number-facet boundaries must be +# two to a thousand distinct numbers in ascending order, so too few, too many, +# non-ascending, duplicate adjacent, or non-numeric boundaries are rejected. +SEARCHMETA_FACET_BOUNDARIES_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "boundaries_single_element", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "number", "path": "n", "boundaries": [0]}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a number facet with fewer than two boundaries", + ), + StageTestCase( + "boundaries_above_max", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": list(range(1_001)), + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a number facet with more than the maximum boundary count", + ), + StageTestCase( + "boundaries_unsorted", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [40, 0, 20]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject non-ascending number facet boundaries", + ), + StageTestCase( + "boundaries_duplicate_adjacent", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 0, 25]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject duplicate adjacent number facet boundaries as not distinct", + ), + StageTestCase( + "boundaries_non_numeric", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": ["a", "b"]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject non-numeric number facet boundaries", + ), +] + +# Property [Facet Date Boundaries Validation]: date-facet boundaries must be two +# to a thousand distinct datetimes in ascending order, and numeric (non-datetime) +# boundaries are rejected. Too few, non-ascending, and duplicate adjacent +# boundaries are rejected as they are for number facets. +SEARCHMETA_FACET_DATE_BOUNDARIES_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "date_boundaries_numeric", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "date", "path": "n", "boundaries": [0, 25]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject numeric boundaries for a date facet", + ), + StageTestCase( + "date_boundaries_single_element", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "date", + "path": "n", + "boundaries": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a date facet with fewer than two boundaries", + ), + StageTestCase( + "date_boundaries_unsorted", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "date", + "path": "n", + "boundaries": [ + datetime(2024, 12, 1, tzinfo=timezone.utc), + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject non-ascending date facet boundaries", + ), + StageTestCase( + "date_boundaries_duplicate_adjacent", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "date", + "path": "n", + "boundaries": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject duplicate adjacent date facet boundaries as not distinct", + ), +] + +# Property [Facet NumBuckets Bounds]: a string-facet numBuckets outside +# [1..1000] is rejected. +SEARCHMETA_FACET_NUMBUCKETS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "numbuckets_zero", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "string", "path": "cat", "numBuckets": 0}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a numBuckets below the lower bound", + ), + StageTestCase( + "numbuckets_above_max", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "string", "path": "cat", "numBuckets": 1001}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a numBuckets above the upper bound", + ), +] + +# Property [Facet NumBuckets Type]: numBuckets must be a whole-number integer, so +# non-integer-typed values, including fractional doubles and decimals, are +# rejected. A null numBuckets is treated as the default, so it is excluded. +SEARCHMETA_FACET_NUMBUCKETS_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"numbuckets_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "string", "path": "cat", "numBuckets": val}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} numBuckets as not a whole-number integer", + ) + for tid, val in [ + ("string", "2"), + ("double_fractional", 2.5), + ("decimal128", Decimal128("2")), + ("bool", True), + ("object", {"a": 1}), + ("array", [2]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet String Boundaries Unrecognized]: a string facet rejects the +# boundaries field as unrecognized. +SEARCHMETA_FACET_STRING_BOUNDARIES_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_facet_with_boundaries", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "string", "path": "cat", "boundaries": [0, 25]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject boundaries on a string facet as an unrecognized field", + ), +] + +# Property [Facet Number NumBuckets Unrecognized]: a number facet rejects the +# numBuckets field as unrecognized. +SEARCHMETA_FACET_NUMBER_NUMBUCKETS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "number_facet_with_numbuckets", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 25], + "numBuckets": 1, + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject numBuckets on a number facet as an unrecognized field", + ), +] + +# Property [Facet String Default Unrecognized]: a string facet rejects the +# default field as unrecognized, since only number and date facets carry a +# default overflow bucket. +SEARCHMETA_FACET_STRING_DEFAULT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_facet_with_default", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "string", "path": "cat", "default": "other"}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject default on a string facet as an unrecognized field", + ), +] + +# Property [Facet Date NumBuckets Unrecognized]: a date facet rejects the +# numBuckets field as unrecognized, since only string facets carry numBuckets. +SEARCHMETA_FACET_DATE_NUMBUCKETS_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "date_facet_with_numbuckets", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "date", + "path": "d", + "boundaries": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + "numBuckets": 2, + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject numBuckets on a date facet as an unrecognized field", + ), +] + +# Property [Facet Token Mapping]: a string facet on a dynamically-indexed field +# is rejected because dynamic mapping does not token-index string fields. +SEARCHMETA_FACET_TOKEN_MAPPING_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_facet_dynamic_field", + pipeline=[ + {"$searchMeta": {"facet": {"facets": {"nf": {"type": "string", "path": "cat"}}}}} + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject string faceting on a dynamically-indexed string field", + ), +] + +SEARCHMETA_SPEC_FACET_BOUNDARY_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_FACET_BOUNDARIES_ERROR_TESTS + + SEARCHMETA_FACET_DATE_BOUNDARIES_ERROR_TESTS + + SEARCHMETA_FACET_NUMBUCKETS_ERROR_TESTS + + SEARCHMETA_FACET_NUMBUCKETS_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_STRING_BOUNDARIES_ERROR_TESTS + + SEARCHMETA_FACET_NUMBER_NUMBUCKETS_ERROR_TESTS + + SEARCHMETA_FACET_STRING_DEFAULT_ERROR_TESTS + + SEARCHMETA_FACET_DATE_NUMBUCKETS_ERROR_TESTS + + SEARCHMETA_FACET_TOKEN_MAPPING_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_SPEC_FACET_BOUNDARY_ERROR_TESTS)) +def test_searchMeta_spec_facet_boundary_errors(search_collection, test_case: StageTestCase): + """Test $searchMeta facet boundary, numBuckets, and token-mapping errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_errors.py new file mode 100644 index 000000000..df20f215b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_facet_errors.py @@ -0,0 +1,414 @@ +"""Tests for $searchMeta facet collector and definition spec errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_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 + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Facet Value Not A Document]: a present facet value that is not a +# document is rejected. +SEARCHMETA_FACET_VALUE_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_not_document_{tid}", + pipeline=[{"$searchMeta": {"facet": val}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Facets Required]: a facet collector whose facets field is +# omitted or null is rejected. +SEARCHMETA_FACETS_REQUIRED_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facets_omitted", + pipeline=[{"$searchMeta": {"facet": {}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a facet collector with facets omitted", + ), + StageTestCase( + "facets_null", + pipeline=[{"$searchMeta": {"facet": {"facets": None}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat a null facets value as field-absent and require facets", + ), +] + +# Property [Facet Facets Not A Document]: a present facet.facets value that is +# not a document is rejected. +SEARCHMETA_FACETS_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facets_not_document_{tid}", + pipeline=[{"$searchMeta": {"facet": {"facets": val}}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet.facets value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Facets Empty]: an empty facet.facets document is rejected. +SEARCHMETA_FACETS_EMPTY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facets_empty", + pipeline=[{"$searchMeta": {"facet": {"facets": {}}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an empty facet.facets document", + ), +] + +# Property [Facet Operator Empty]: an empty facet.operator document is rejected. +SEARCHMETA_FACET_OPERATOR_EMPTY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_operator_empty", + pipeline=[ + { + "$searchMeta": { + "facet": { + "operator": {}, + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}}, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an empty facet operator by listing the valid operators", + ), +] + +# Property [Facet Definition Required Fields]: a facet definition missing its +# type or path is rejected. +SEARCHMETA_FACET_DEF_REQUIRED_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_def_missing_type", + pipeline=[ + {"$searchMeta": {"facet": {"facets": {"nf": {"path": "n", "boundaries": [0, 25]}}}}} + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a facet definition missing its type", + ), + StageTestCase( + "facet_def_missing_path", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "number", "boundaries": [0, 25]}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a facet definition missing its path", + ), + StageTestCase( + "facet_def_missing_boundaries", + pipeline=[{"$searchMeta": {"facet": {"facets": {"nf": {"type": "number", "path": "n"}}}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a number facet definition missing its boundaries", + ), +] + +# Property [Facet Definition Type Value]: a facet definition type outside +# {date, number, string} is rejected. +SEARCHMETA_FACET_DEF_TYPE_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_def_type_unknown", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "foo", "path": "n", "boundaries": [0, 25]}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a facet definition type outside the allowed set", + ), +] + +# Property [Facet Definition Not A Document]: a facet definition value that is +# not a document is rejected. +SEARCHMETA_FACET_DEF_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_def_not_document_{tid}", + pipeline=[{"$searchMeta": {"facet": {"facets": {"nf": val}}}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet definition value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Path Type]: a facet definition path must be a string, so a +# non-string path is rejected. A null path is treated as missing, so it is +# excluded. +SEARCHMETA_FACET_PATH_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_path_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": val, "boundaries": [0, 25]}} + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet path as not a string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("object", {"a": 1}), + ("array", ["n"]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Boundaries Type]: a number-facet boundaries value must be an +# array, so a non-array boundaries value is rejected. A null boundaries is +# treated as missing, so it is excluded. +SEARCHMETA_FACET_BOUNDARIES_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_boundaries_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"nf": {"type": "number", "path": "n", "boundaries": val}}} + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} boundaries value as not an array", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Default Type]: a number-facet default overflow bucket name +# must be a string, so a non-string default is rejected. A null default is +# treated as no default, so it is excluded. +SEARCHMETA_FACET_DEFAULT_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"facet_default_type_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 5], + "default": val, + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} facet default as not a string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("object", {"a": 1}), + ("array", ["over"]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Facet Unrecognized Field]: an unrecognized field at the facet +# definition or collector level, including a count modifier placed inside the +# collector, is rejected. +SEARCHMETA_FACET_UNKNOWN_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "facet_def_unknown_field", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": { + "nf": { + "type": "number", + "path": "n", + "boundaries": [0, 25], + "bogus": 1, + } + } + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an unrecognized field in a facet definition", + ), + StageTestCase( + "facet_collector_unknown_field", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}}, + "bogus": 1, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an unrecognized field at the facet collector level", + ), + StageTestCase( + "facet_collector_count_inside", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}}, + "count": {"type": "total"}, + } + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a count modifier placed inside the facet collector", + ), +] + +SEARCHMETA_SPEC_FACET_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_FACET_VALUE_TYPE_ERROR_TESTS + + SEARCHMETA_FACETS_REQUIRED_ERROR_TESTS + + SEARCHMETA_FACETS_TYPE_ERROR_TESTS + + SEARCHMETA_FACETS_EMPTY_ERROR_TESTS + + SEARCHMETA_FACET_OPERATOR_EMPTY_ERROR_TESTS + + SEARCHMETA_FACET_DEF_REQUIRED_ERROR_TESTS + + SEARCHMETA_FACET_DEF_TYPE_VALUE_ERROR_TESTS + + SEARCHMETA_FACET_DEF_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_PATH_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_BOUNDARIES_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_DEFAULT_TYPE_ERROR_TESTS + + SEARCHMETA_FACET_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_SPEC_FACET_ERROR_TESTS)) +def test_searchMeta_spec_facet_errors(search_collection, test_case: StageTestCase): + """Test $searchMeta facet collector and definition spec errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_operator_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_operator_errors.py new file mode 100644 index 000000000..f0d6c6f23 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_spec_operator_errors.py @@ -0,0 +1,222 @@ +"""Tests for $searchMeta operator/collector presence and index spec errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_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 + +pytestmark = pytest.mark.requires(search=True) + + +# Property [Operator Value Not A Document]: a present operator-key value that is +# not a document is rejected. +SEARCHMETA_OPERATOR_VALUE_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"operator_value_{tid}", + pipeline=[{"$searchMeta": {"text": val}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} operator value as not a document", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [No Operator Or Collector Present]: a spec that the server reads as +# having no recognized operator and no collector is rejected. +SEARCHMETA_NO_OPERATOR_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "no_operator_unknown_name", + pipeline=[{"$searchMeta": {"unknownop": {"query": "quick", "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an unrecognized operator name as no operator present", + ), + StageTestCase( + "no_operator_empty_spec", + pipeline=[{"$searchMeta": {}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an empty spec as no operator present", + ), + StageTestCase( + "no_operator_modifier_only", + pipeline=[{"$searchMeta": {"count": {"type": "total"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a spec with only modifiers as no operator present", + ), + StageTestCase( + "no_operator_capitalized_key", + pipeline=[{"$searchMeta": {"Text": {"query": "quick", "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat a capitalized operator key as unrecognized, not present", + ), + StageTestCase( + "no_operator_untrimmed_key", + pipeline=[{"$searchMeta": {"text ": {"query": "quick", "path": "title"}}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat an untrimmed operator key as unrecognized, not present", + ), + StageTestCase( + "no_operator_value_null", + pipeline=[{"$searchMeta": {"text": None}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat a null operator value as field-absent, not present", + ), + StageTestCase( + "no_operator_facet_null", + pipeline=[{"$searchMeta": {"facet": None}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should treat a null facet value as field-absent, not present", + ), +] + +# Property [Conflicting Operators And Collector]: a spec carrying more than one +# query specifier (a top-level operator plus a facet collector, or two top-level +# operators) is rejected. +SEARCHMETA_OPERATOR_CONFLICT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "conflict_operator_and_collector", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "facet": { + "facets": {"nf": {"type": "number", "path": "n", "boundaries": [0, 25]}} + }, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a spec carrying both an operator and a facet collector", + ), + StageTestCase( + "conflict_two_operators", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "equals": {"path": "n", "value": 1}, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject a spec carrying two top-level operators", + ), +] + +# Property [Index Not A String]: an index value that is not a string and not +# null is rejected. +SEARCHMETA_INDEX_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"index_not_string_{tid}", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}, "index": val}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} index value as not a string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ZERO), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index Empty String]: an empty-string index is rejected. +SEARCHMETA_INDEX_EMPTY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "index_empty_string", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}, "index": ""}}], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject an empty-string index value", + ), +] + +# Property [Unknown Top-Level Option]: a top-level field that is not a recognized +# option is rejected; matching is exact, case-sensitive, and not +# whitespace-trimmed. ($search output options like sort/highlight are accepted as +# no-ops, so they are not unrecognized and not asserted here.) +SEARCHMETA_UNKNOWN_OPTION_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + f"unknown_option_{suffix}", + pipeline=[{"$searchMeta": {"text": {"query": "quick", "path": "title"}, name: value}}], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {suffix} top-level option as an unrecognized field", + ) + for name, value, suffix in [ + ("bogus", 1, "garbage_name"), + ("Index", "default", "capitalized_index"), + ("index ", "default", "trailing_space_index"), + ] +] + +SEARCHMETA_SPEC_OPERATOR_ERROR_TESTS: list[StageTestCase] = ( + SEARCHMETA_OPERATOR_VALUE_TYPE_ERROR_TESTS + + SEARCHMETA_NO_OPERATOR_ERROR_TESTS + + SEARCHMETA_OPERATOR_CONFLICT_ERROR_TESTS + + SEARCHMETA_INDEX_TYPE_ERROR_TESTS + + SEARCHMETA_INDEX_EMPTY_ERROR_TESTS + + SEARCHMETA_UNKNOWN_OPTION_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_SPEC_OPERATOR_ERROR_TESTS)) +def test_searchMeta_spec_operator_errors(search_collection, test_case: StageTestCase): + """Test $searchMeta operator/collector presence and index spec errors.""" + result = execute_command( + search_collection, + { + "aggregate": search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_stored_source.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_stored_source.py new file mode 100644 index 000000000..002f8caf9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_stored_source.py @@ -0,0 +1,171 @@ +"""Tests for the $searchMeta returnStoredSource option and behavior.""" + +from __future__ import annotations + +import datetime +from collections.abc import Iterator + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNKNOWN_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_ONE_AND_HALF + +pytestmark = pytest.mark.requires(search=True) + + +_STORED_SOURCE_DOCS = [ + {"_id": 1, "title": "the quick brown fox", "body": "lazy dog"}, + {"_id": 2, "title": "slow green turtle", "body": "quick nap"}, + {"_id": 3, "title": "a quick quick rabbit", "body": "fast"}, +] + + +@pytest.fixture(scope="module") +def stored_source_collection(engine_client, worker_id) -> Iterator[Collection]: + """Module-scoped collection whose index stores only the title source field.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::stored_source_collection", + "searchmeta_stored_source", + _STORED_SOURCE_DOCS, + [ + { + "name": "default", + "definition": { + "mappings": {"dynamic": True}, + "storedSource": {"include": ["title"]}, + }, + } + ], + ) as coll: + yield coll + + +# Property [ReturnStoredSource Acceptance]: returnStoredSource accepts a boolean +# with no coercion, and because a metadata stage returns no documents the count +# envelope is returned unchanged whether it is true or false. +SEARCHMETA_RETURN_STORED_SOURCE_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_stored_source_false", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnStoredSource": False, + } + } + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should accept returnStoredSource false and still return its count", + ), + CollectionFixtureTestCase( + "return_stored_source_true", + collection_fixture="stored_source_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnStoredSource": True, + } + } + ], + expected=[{"count": {"lowerBound": Int64(2)}}], + msg="$searchMeta should accept returnStoredSource true against a storedSource-configured " + "index and still return only the count", + ), +] + +# Property [ReturnStoredSource Without Configured Source]: returnStoredSource true +# against an index that does not configure storedSource is rejected. +SEARCHMETA_RETURN_STORED_SOURCE_CONFIG_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "return_stored_source_unconfigured", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnStoredSource": True, + } + } + ], + error_code=UNKNOWN_ERROR, + msg="$searchMeta should reject returnStoredSource true against an index with no " + "storedSource configured", + ), +] + +# Property [ReturnStoredSource Boolean Type]: the returnStoredSource option must +# be a boolean, so a value of any non-boolean BSON type is rejected. A null +# returnStoredSource is treated as the default, so it is excluded. +SEARCHMETA_RETURN_STORED_SOURCE_TYPE_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + f"return_stored_source_type_{tid}", + collection_fixture="search_collection", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "quick", "path": "title"}, + "returnStoredSource": val, + } + } + ], + error_code=UNKNOWN_ERROR, + msg=f"$searchMeta should reject a {tid} returnStoredSource as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("0123456789abcdef01234567")), + ("datetime", datetime.datetime(2020, 1, 1)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +SEARCHMETA_STORED_SOURCE_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_RETURN_STORED_SOURCE_TESTS + + SEARCHMETA_RETURN_STORED_SOURCE_CONFIG_ERROR_TESTS + + SEARCHMETA_RETURN_STORED_SOURCE_TYPE_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_STORED_SOURCE_TESTS)) +def test_searchMeta_stored_source(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta returnStoredSource acceptance, config, and type validation.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_string_facet.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_string_facet.py new file mode 100644 index 000000000..c0c5e7475 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_string_facet.py @@ -0,0 +1,198 @@ +"""Tests for $searchMeta string-facet bucket selection and ordering.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + build_collection, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +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 INT64_ZERO + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def string_facet_collection(engine_client, worker_id) -> Iterator[Collection]: + """Indexed collection with a token mapping for string faceting. + + String facets are rejected under a plain dynamic mapping, so this collection + declares an explicit token mapping. Category counts (a=3, b=2, c=1) make + truncation and ordering by count observable. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::string_facet_collection", + "searchmeta_string_facet", + [ + {"_id": 1, "cat": "a"}, + {"_id": 2, "cat": "b"}, + {"_id": 3, "cat": "a"}, + {"_id": 4, "cat": "c"}, + {"_id": 5, "cat": "b"}, + {"_id": 6, "cat": "a"}, + ], + [ + { + "name": "default", + "definition": { + "mappings": {"dynamic": True, "fields": {"cat": [{"type": "token"}]}} + }, + } + ], + ) as coll: + yield coll + + +# Property [Facet String NumBuckets]: numBuckets greater than the distinct value +# count returns all buckets, while fewer truncates to the top-N values by count. +SEARCHMETA_STRING_NUMBUCKETS_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_numbuckets_all", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"sf": {"type": "string", "path": "cat", "numBuckets": 10}}} + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(6)}, + "facet": { + "sf": { + "buckets": [ + {"_id": "a", "count": Int64(3)}, + {"_id": "b", "count": Int64(2)}, + {"_id": "c", "count": Int64(1)}, + ] + } + }, + } + ], + msg="$searchMeta string facet should return all buckets when numBuckets exceeds the " + "distinct value count", + ), + StageTestCase( + "string_numbuckets_topn", + pipeline=[ + { + "$searchMeta": { + "facet": {"facets": {"sf": {"type": "string", "path": "cat", "numBuckets": 2}}} + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(6)}, + "facet": { + "sf": { + "buckets": [ + {"_id": "a", "count": Int64(3)}, + {"_id": "b", "count": Int64(2)}, + ] + } + }, + } + ], + msg="$searchMeta string facet should truncate to the top-N values by count when " + "numBuckets is below the distinct value count", + ), +] + +# Property [Facet String NumBuckets Type Coercion]: numBuckets accepts any +# whole-number integer type, so an Int64 and a whole-number double behave like +# the equivalent int32. +SEARCHMETA_STRING_NUMBUCKETS_COERCION_TESTS: list[StageTestCase] = [ + StageTestCase( + f"string_numbuckets_{tid}", + pipeline=[ + { + "$searchMeta": { + "facet": { + "facets": {"sf": {"type": "string", "path": "cat", "numBuckets": val}} + } + } + } + ], + expected=[ + { + "count": {"lowerBound": Int64(6)}, + "facet": { + "sf": { + "buckets": [ + {"_id": "a", "count": Int64(3)}, + {"_id": "b", "count": Int64(2)}, + ] + } + }, + } + ], + msg=f"$searchMeta string facet should accept a {tid} numBuckets and truncate to the " + "top-N values by count", + ) + for tid, val in [("int64", Int64(2)), ("whole_double", 2.0)] +] + +# Property [Facet Empty Buckets]: a no-match query yields an empty buckets array +# for a string facet, which emits only buckets backed by matching values. +SEARCHMETA_STRING_EMPTY_TESTS: list[StageTestCase] = [ + StageTestCase( + "string_facet_no_match_empty_buckets", + pipeline=[ + { + "$searchMeta": { + "facet": { + "operator": {"text": {"query": "nonexistentxyz", "path": "cat"}}, + "facets": {"sf": {"type": "string", "path": "cat"}}, + } + } + } + ], + expected=[ + { + "count": {"lowerBound": INT64_ZERO}, + "facet": {"sf": {"buckets": []}}, + } + ], + msg="$searchMeta string facet should return an empty buckets array when no document " + "matches the query", + ), +] + +SEARCHMETA_STRING_FACET_TESTS: list[StageTestCase] = ( + SEARCHMETA_STRING_NUMBUCKETS_TESTS + + SEARCHMETA_STRING_NUMBUCKETS_COERCION_TESTS + + SEARCHMETA_STRING_EMPTY_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_STRING_FACET_TESTS)) +def test_searchMeta_string_facet(string_facet_collection, test_case: StageTestCase): + """Test $searchMeta string facet bucket selection and ordering.""" + result = execute_command( + string_facet_collection, + { + "aggregate": string_facet_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_threshold_bounds.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_threshold_bounds.py new file mode 100644 index 000000000..b142a13c2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_searchMeta_threshold_bounds.py @@ -0,0 +1,113 @@ +"""Tests for $searchMeta count behavior above the exact-count threshold.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + LARGE_MATCH_COUNT, + build_collection, +) +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Gte, Lte + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def large_search_collection(engine_client, worker_id) -> Iterator[Collection]: + """Large indexed collection where every document matches one query.""" + with build_collection( + engine_client, + worker_id, + f"{__name__}::large_search_collection", + "searchmeta_large", + [{"_id": i, "title": "widget"} for i in range(LARGE_MATCH_COUNT)], + [{"name": "default", "definition": {"mappings": {"dynamic": True}}}], + ) as coll: + yield coll + + +# Property [Count Type Above Threshold]: when the match count exceeds the +# threshold, count.type lowerBound returns a value between the threshold and the +# match count, while count.type total stays exact. +SEARCHMETA_THRESHOLD_BOUND_TESTS: list[StageTestCase] = [ + StageTestCase( + "threshold_bound_explicit_low", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "widget", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 2000}, + } + } + ], + expected={"count": {"lowerBound": [Gte(2000), Lte(LARGE_MATCH_COUNT)]}}, + msg="$searchMeta count.type lowerBound should return at least the threshold and at " + "most the match count", + ), + StageTestCase( + "threshold_bound_explicit_high", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "widget", "path": "title"}, + "count": {"type": "lowerBound", "threshold": 5000}, + } + } + ], + expected={"count": {"lowerBound": [Gte(5000), Lte(LARGE_MATCH_COUNT)]}}, + msg="$searchMeta count.type lowerBound should track a higher threshold and stay at " + "most the match count", + ), + StageTestCase( + "threshold_bound_default", + pipeline=[{"$searchMeta": {"text": {"query": "widget", "path": "title"}}}], + expected={"count": {"lowerBound": [Gte(1000), Lte(LARGE_MATCH_COUNT)]}}, + msg="$searchMeta should apply a default threshold of 1000 when count is omitted, so the " + "result is at least 1000 and at most the match count", + ), + StageTestCase( + "threshold_bound_total_exact_above_threshold", + pipeline=[ + { + "$searchMeta": { + "text": {"query": "widget", "path": "title"}, + "count": {"type": "total"}, + } + } + ], + expected=[{"count": {"total": Int64(LARGE_MATCH_COUNT)}}], + msg="$searchMeta count.type total should return the exact match count even when it far " + "exceeds the threshold", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_THRESHOLD_BOUND_TESTS)) +def test_searchMeta_threshold_bounds(large_search_collection, test_case: StageTestCase): + """Test $searchMeta count behavior above the threshold.""" + result = execute_command( + large_search_collection, + { + "aggregate": large_search_collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_smoke_searchMeta.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_smoke_searchMeta.py index ec3983536..e250fcc2b 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_smoke_searchMeta.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/test_smoke_searchMeta.py @@ -1,18 +1,13 @@ -""" -Smoke test for $searchMeta stage. - -Tests basic $searchMeta stage functionality. -""" +"""Smoke test for the $searchMeta stage.""" import pytest from documentdb_tests.framework.assertions import assertSuccessPartial from documentdb_tests.framework.executor import execute_command -pytestmark = pytest.mark.smoke +pytestmark = [pytest.mark.smoke, pytest.mark.requires(search=True)] -@pytest.mark.skip(reason="Requires Atlas Search configuration - not available on standard MongoDB") def test_smoke_searchMeta(collection): """Test basic $searchMeta stage behavior.""" collection.insert_many([{"_id": 1, "title": "test document"}]) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/searchMeta_common.py b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/searchMeta_common.py new file mode 100644 index 000000000..e08560f76 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/searchMeta/utils/searchMeta_common.py @@ -0,0 +1,110 @@ +"""Shared infrastructure for $searchMeta stage tests.""" + +from __future__ import annotations + +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any + +from pymongo.collection import Collection +from pymongo.database import Database + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.fixtures import cleanup_database, generate_database_name + + +@dataclass(frozen=True) +class CollectionFixtureTestCase(StageTestCase): + """A $searchMeta case whose collection is supplied by a named fixture. + + ``collection_fixture`` names the module-scoped fixture that builds the + search-indexed collection, letting cases targeting different collection + states share one parametrized test. A test using this must declare + ``engine_client`` directly so target parametrization fires before the + fixture resolves via ``request.getfixturevalue``. + """ + + collection_fixture: str = "" + + +# Shared dataset; the match counts the tests assert are derived from these +# documents. +SEARCH_DOCS: list[dict[str, Any]] = [ + {"_id": 1, "title": "the quick brown fox", "n": 1, "cat": "a"}, + {"_id": 2, "title": "quick red fox", "n": 5, "cat": "b"}, + {"_id": 3, "title": "lazy brown dog", "n": 10, "cat": "a"}, + {"_id": 4, "title": "slow green turtle", "n": 15, "cat": "c"}, + {"_id": 5, "title": "quick small mouse", "n": 20, "cat": "b"}, +] + +# Match count for the large collection, chosen to exceed the lower-bound count's +# exact-count threshold. Referenced by the fixture and the test expectations. +LARGE_MATCH_COUNT = 10_000 + + +def wait_for_ready(db: Database, name: str) -> None: + """Block until every search index on the collection reports READY.""" + # A search index build is asynchronous; allow generous time to reach READY. + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + batch = db.command({"listSearchIndexes": name})["cursor"]["firstBatch"] + if batch and all(idx.get("status") == "READY" for idx in batch): + return + time.sleep(1) + raise TimeoutError(f"search index on {name!r} did not reach READY") + + +@contextmanager +def build_collection( + engine_client, worker_id, tag: str, coll_name: str, docs, indexes +) -> Iterator[Collection]: + """Yield a module-scoped collection built from a single recipe. + + ``tag`` namespaces the database so distinct fixtures and modules never + collide. ``docs=None`` leaves the collection uncreated, ``docs=[]`` creates + it empty, and a non-empty list creates and populates it. When ``indexes`` is + given they are built as search indexes and awaited to READY. + """ + db_name = generate_database_name(tag, worker_id) + # Database name is deterministic, so drop any leftover from a crashed run. + cleanup_database(engine_client, db_name) + db = engine_client[db_name] + coll = db[coll_name] + try: + if docs is None: + pass + elif docs: + coll.insert_many(docs) + else: + db.create_collection(coll.name) + if indexes: + db.command({"createSearchIndexes": coll.name, "indexes": indexes}) + wait_for_ready(db, coll.name) + yield coll + finally: + cleanup_database(engine_client, db_name) + + +@contextmanager +def open_search_collection(engine_client, worker_id, tag: str) -> Iterator[Collection]: + """Yield the standard metadata search collection. + + Carries a "default" and a non-default "alt_idx" index so index-selection + cases can target each by name. + """ + with build_collection( + engine_client, + worker_id, + tag, + "searchmeta", + SEARCH_DOCS, + [ + {"name": "default", "definition": {"mappings": {"dynamic": True}}}, + {"name": "alt_idx", "definition": {"mappings": {"dynamic": True}}}, + ], + ) as coll: + yield coll diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_searchMeta.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_searchMeta.py new file mode 100644 index 000000000..674070878 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_searchMeta.py @@ -0,0 +1,300 @@ +"""Tests for $searchMeta pipeline position constraints and stage combinations.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from bson import Int64 +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.searchMeta.utils.searchMeta_common import ( # noqa: E501 + SEARCH_DOCS, + CollectionFixtureTestCase, + build_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FACET_PIPELINE_INVALID_STAGE_ERROR, + NOT_FIRST_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.requires(search=True) + + +@pytest.fixture(scope="module") +def noindex_collection(engine_client, worker_id) -> Iterator[Collection]: + """Provide a populated collection that has no search index. + + Position and context errors are parse-time rejections, so this collection + deliberately omits the search index to confirm the position check fires + regardless of index state and to let sub-pipeline cases self-reference an + existing collection. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::noindex_collection", + "searchmeta_noindex", + SEARCH_DOCS, + None, + ) as coll: + yield coll + + +@pytest.fixture(scope="module") +def search_collection(engine_client, worker_id) -> Iterator[Collection]: + """Provide a populated collection with a READY dynamic search index. + + Building a search index is an expensive asynchronous operation, so the + collection is created once per module and shared across the placement cases, + which only read from it. + """ + with build_collection( + engine_client, + worker_id, + f"{__name__}::search_collection", + "searchmeta", + SEARCH_DOCS, + [{"name": "default", "definition": {"mappings": {"dynamic": True}}}], + ) as coll: + yield coll + + +# Property [Stage Position and Context]: a first-position $searchMeta permits a +# single benign trailing stage with the one metadata document passing through, +# and $searchMeta is allowed as the first stage of a $unionWith or $lookup +# sub-pipeline. +SEARCHMETA_PLACEMENT_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "placement_trailing_limit", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + {"$limit": 1}, + ], + expected=[{"count": {"lowerBound": Int64(3)}}], + msg="$searchMeta should permit a single trailing $limit and pass the one metadata " + "document through", + ), + CollectionFixtureTestCase( + "placement_unionwith_subpipeline", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + { + "$unionWith": { + "coll": "searchmeta", + "pipeline": [{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + } + }, + ], + expected=[ + {"count": {"lowerBound": Int64(3)}}, + {"count": {"lowerBound": Int64(3)}}, + ], + msg="$searchMeta should be allowed as the first stage of a $unionWith sub-pipeline " + "alongside a first-position main $searchMeta", + ), + CollectionFixtureTestCase( + "placement_lookup_subpipeline", + collection_fixture="search_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + { + "$lookup": { + "from": "searchmeta", + "pipeline": [{"$searchMeta": {"text": {"query": "quick", "path": "title"}}}], + "as": "meta", + } + }, + ], + expected=[ + { + "count": {"lowerBound": Int64(3)}, + "meta": [{"count": {"lowerBound": Int64(3)}}], + } + ], + msg="$searchMeta should be allowed as the first stage of a $lookup sub-pipeline and " + "return the metadata document per joined row", + ), +] + +# Property [Stage Position and Context Errors]: $searchMeta is rejected with +# NOT_FIRST_STAGE_ERROR whenever it is not the first stage of its main pipeline +# or of a $unionWith/$lookup sub-pipeline, and with +# FACET_PIPELINE_INVALID_STAGE_ERROR inside a $facet sub-pipeline; the position +# check fires regardless of index state. +SEARCHMETA_POSITION_ERROR_TESTS: list[CollectionFixtureTestCase] = [ + CollectionFixtureTestCase( + "error_after_match", + collection_fixture="noindex_collection", + pipeline=[ + {"$match": {"n": {"$gt": 0}}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after a $match should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_after_project", + collection_fixture="noindex_collection", + pipeline=[ + {"$project": {"title": 1}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after a $project should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_after_limit", + collection_fixture="noindex_collection", + pipeline=[ + {"$limit": 1}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after a $limit should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_after_addfields", + collection_fixture="noindex_collection", + pipeline=[ + {"$addFields": {"m": 1}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after an $addFields should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_after_empty_match", + collection_fixture="noindex_collection", + pipeline=[ + {"$match": {}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta after an empty $match should still be rejected as not the first " + "stage because the empty $match is not optimized away", + ), + CollectionFixtureTestCase( + "error_second_searchMeta_adjacent", + collection_fixture="noindex_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="A second adjacent $searchMeta should be rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_second_searchMeta_separated", + collection_fixture="noindex_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + {"$limit": 1}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="A second $searchMeta separated from the first should be rejected as not the " + "first stage", + ), + CollectionFixtureTestCase( + "error_inside_facet_first", + collection_fixture="noindex_collection", + pipeline=[ + { + "$facet": { + "meta": [ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + } + }, + ], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$searchMeta as the first stage of a $facet sub-pipeline should be rejected", + ), + CollectionFixtureTestCase( + "error_inside_facet_not_first", + collection_fixture="noindex_collection", + pipeline=[ + { + "$facet": { + "meta": [ + {"$limit": 1}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + } + }, + ], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$searchMeta inside a $facet sub-pipeline should be rejected regardless of its " + "position within the sub-pipeline", + ), + CollectionFixtureTestCase( + "error_unionwith_subpipeline_not_first", + collection_fixture="noindex_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + { + "$unionWith": { + "coll": "searchmeta_noindex", + "pipeline": [ + {"$match": {}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + } + }, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta that is not first inside a $unionWith sub-pipeline should be " + "rejected as not the first stage", + ), + CollectionFixtureTestCase( + "error_lookup_subpipeline_not_first", + collection_fixture="noindex_collection", + pipeline=[ + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + { + "$lookup": { + "from": "searchmeta_noindex", + "pipeline": [ + {"$match": {}}, + {"$searchMeta": {"text": {"query": "quick", "path": "title"}}}, + ], + "as": "meta", + } + }, + ], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$searchMeta that is not first inside a $lookup sub-pipeline should be " + "rejected as not the first stage", + ), +] + +SEARCHMETA_POSITION_TESTS: list[CollectionFixtureTestCase] = ( + SEARCHMETA_PLACEMENT_TESTS + SEARCHMETA_POSITION_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SEARCHMETA_POSITION_TESTS)) +def test_searchMeta_position(engine_client, request, test_case: CollectionFixtureTestCase): + """Test $searchMeta pipeline position constraints, combinations, and rejections.""" + collection = request.getfixturevalue(test_case.collection_fixture) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/framework/engine_registry.py b/documentdb_tests/framework/engine_registry.py index 774ab7810..1e2bf1664 100644 --- a/documentdb_tests/framework/engine_registry.py +++ b/documentdb_tests/framework/engine_registry.py @@ -102,6 +102,15 @@ def _is_reachable(connection_string: str) -> bool: # replSetInitiate error code when the set is already initiated (e.g. a race # between concurrent callers); treated as success. _ALREADY_INITIALIZED = 23 +# createUser error code when the user already exists (idempotent re-runs). +_USER_ALREADY_EXISTS = 51003 + +# The user mongot authenticates as to replicate from a search-enabled mongod. +# Its name and password are a fixed local-dev secret matched by the mongot +# sidecar's config (see dev/mongot.yml and the mongot service in +# dev/compose.yaml); it is not a real credential. +_SEARCH_SYNC_USER = "searchSyncUser" +_SEARCH_SYNC_PASSWORD = "searchSyncPassword" def ensure_initiated(connection_string: str, timeout_s: float = 30.0) -> None: @@ -120,12 +129,14 @@ def ensure_initiated(connection_string: str, timeout_s: float = 30.0) -> None: that already initiated it (AlreadyInitialized) is tolerated. After initiating, it waits up to ``timeout_s`` for a primary to be elected - so callers can write immediately. + so callers can write immediately. A search-enabled mongod additionally has + the searchCoordinator user mongot needs provisioned once it is primary. """ client: MongoClient = MongoClient(connection_string, serverSelectionTimeoutMS=5000) try: try: client.admin.command("replSetGetStatus") + _ensure_search_user(client) # Idempotent; a no-op off a search target. return # Already initiated. except OperationFailure as exc: if exc.code != _NOT_YET_INITIALIZED: @@ -140,6 +151,7 @@ def ensure_initiated(connection_string: str, timeout_s: float = 30.0) -> None: deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: if client.admin.command("hello").get("isWritablePrimary"): + _ensure_search_user(client) return time.sleep(0.5) raise TimeoutError( @@ -149,6 +161,29 @@ def ensure_initiated(connection_string: str, timeout_s: float = 30.0) -> None: client.close() +def _ensure_search_user(client: MongoClient) -> None: + """Provision the searchCoordinator user a search-enabled mongod needs. + + A search target points at a mongot sidecar (a non-empty ``mongotHost``). + mongot replicates from this mongod as an authenticated sync source, so it + needs a user with the searchCoordinator role to log in as. This creates that + user (idempotently) once the server is primary. It is a no-op on a target + without a mongot sidecar. + """ + if not client.admin.command({"getParameter": 1, "mongotHost": 1}).get("mongotHost"): + return # Not a search target. + try: + client.admin.command( + "createUser", + _SEARCH_SYNC_USER, + pwd=_SEARCH_SYNC_PASSWORD, + roles=[{"role": "searchCoordinator", "db": "admin"}], + ) + except OperationFailure as exc: + if exc.code != _USER_ALREADY_EXISTS: + raise + + def live_targets(compose_path: Path = COMPOSE_PATH) -> list[Target]: """Return the declared targets that are currently reachable.""" return [t for t in load_targets(compose_path) if _is_reachable(t.connection_string)] diff --git a/documentdb_tests/framework/preconditions.py b/documentdb_tests/framework/preconditions.py index a42e71716..561b9dec5 100644 --- a/documentdb_tests/framework/preconditions.py +++ b/documentdb_tests/framework/preconditions.py @@ -57,6 +57,7 @@ "unforced_compact": "compact succeeds without force", "reindex": "reIndex is permitted", "local_rename": "renaming into the unreplicated local database is permitted", + "search": "search and vector search surfaces are available", "replication": "replication commands are available (applyOps, oplog access)", "validate_repair": ( "validate with repair/fixMultikey is permitted and background validation " @@ -67,6 +68,10 @@ # The capabilities each (engine, topology) target has. To add an engine or # topology, add an entry here; every test then gates correctly. _CAPABILITIES_BY_PROFILE: dict[tuple[str, str], frozenset[str]] = { + # A replica set, wired to a mongot search sidecar so it also serves the + # search surfaces (see dev/compose.yaml). mongot is transparent to all other + # behavior, so this is a replica set that additionally has the search + # capability, not a distinct topology. ("mongodb", "replica_set"): frozenset( { "change_streams", @@ -76,6 +81,7 @@ "cluster_time", "cluster_read_concern", "quorum_write_concern", + "search", "oplog", "replication", } diff --git a/documentdb_tests/pytest.ini b/documentdb_tests/pytest.ini index b2cd6c4a1..0f577b18f 100644 --- a/documentdb_tests/pytest.ini +++ b/documentdb_tests/pytest.ini @@ -50,6 +50,7 @@ markers = # Timeout for tests (seconds) timeout = 300 +timeout_method = thread # Parallel execution settings # Use with: pytest -n auto From ecd681db374d45f93a617bdd7181b81f5995f5aa Mon Sep 17 00:00:00 2001 From: "Paterson.How" <71473631+PatersonProjects@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:42:49 -0700 Subject: [PATCH 50/51] Add $dbStats tests (#622) Signed-off-by: PatersonProjects --- .../diagnostic/commands/dbStats/__init__.py | 0 .../commands/dbStats/test_dbStats_accuracy.py | 109 ++++++++ .../dbStats/test_dbStats_argument_handling.py | 238 ++++++++++++++++++ .../test_dbStats_collection_scenarios.py | 69 +++++ .../dbStats/test_dbStats_core_behavior.py | 78 ++++++ .../commands/dbStats/test_dbStats_errors.py | 65 +++++ .../test_dbStats_response_structure.py | 169 +++++++++++++ 7 files changed, 728 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_accuracy.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_collection_scenarios.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_response_structure.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_accuracy.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_accuracy.py new file mode 100644 index 000000000..b8be07716 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_accuracy.py @@ -0,0 +1,109 @@ +"""Tests for dbStats accuracy and state changes. + +Covers count fields (collections, objects, indexes) reflecting database +state. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +COUNT_ACCURACY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="collections_count_reflects_created", + setup=[{"create": "c1"}, {"create": "c2"}, {"create": "c3"}], + command={"dbStats": 1}, + use_admin=False, + checks={"collections": Eq(Int64(3))}, + msg="collections should equal the number of created collections", + ), + DiagnosticTestCase( + id="objects_sum_across_collections", + setup=[ + {"insert": "c1", "documents": [{"_id": i} for i in range(4)]}, + {"insert": "c2", "documents": [{"_id": i} for i in range(6)]}, + ], + command={"dbStats": 1}, + use_admin=False, + checks={"objects": Eq(Int64(10))}, + msg="objects should equal the total documents across all collections", + ), + DiagnosticTestCase( + id="indexes_default_plus_created", + setup=[ + {"insert": "c1", "documents": [{"_id": i, "a": i, "b": i} for i in range(5)]}, + { + "createIndexes": "c1", + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ], + command={"dbStats": 1}, + use_admin=False, + checks={"indexes": Eq(Int64(3))}, + msg="indexes should count the default _id index plus created indexes", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COUNT_ACCURACY_TESTS)) +def test_dbStats_count_accuracy(collection, test): + """Test dbStats count fields accurately reflect created collections, documents, and indexes.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_dbStats_scale_divides_data_size(collection): + """Test scale divides reported dataSize by the scale factor (approximately). + + Compares dataSize at scale 1 (bytes) against scale 1024 (KiB). The scaled + value should approximate the unscaled value divided by 1024; a tolerance + absorbs the server's integer truncation, avoiding flakiness. + """ + collection.insert_many([{"_id": i, "data": "x" * 1024} for i in range(50)]) + unscaled = execute_command(collection, {"dbStats": 1, "scale": 1}) + scaled = execute_command(collection, {"dbStats": 1, "scale": 1024}) + expected_scaled = unscaled.get("dataSize") / 1024 + actual_scaled = scaled.get("dataSize") + assertSuccess( + actual_scaled == pytest.approx(expected_scaled, abs=1.0), + expected=True, + raw_res=True, + msg=( + f"scale=1024 dataSize ({actual_scaled}) should approximate " + f"unscaled dataSize / 1024 ({expected_scaled})" + ), + ) + + +def test_dbStats_avg_obj_size_unaffected_by_scale(collection): + """Test avgObjSize is identical regardless of the scale value. + + avgObjSize is always reported in bytes and should not be divided + by the scale factor. + """ + collection.insert_many([{"_id": i, "data": "x" * 100} for i in range(10)]) + unscaled = execute_command(collection, {"dbStats": 1, "scale": 1}) + scaled = execute_command(collection, {"dbStats": 1, "scale": 1024}) + assertSuccess( + scaled.get("avgObjSize"), + expected=unscaled.get("avgObjSize"), + raw_res=True, + msg="avgObjSize should be identical regardless of scale", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_argument_handling.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_argument_handling.py new file mode 100644 index 000000000..b99d73704 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_argument_handling.py @@ -0,0 +1,238 @@ +"""Tests for dbStats command argument handling. + +The value of the ``dbStats`` field is ignored by the server: any value +selects the current database, so every BSON type should be accepted, +including numeric edge cases such as 0, -1, and Infinity. + +Also covers the ``scale`` parameter (type-level acceptance and rejection, +value truncation, and duplicate-key behavior) and the ``freeStorage`` +parameter (type-level acceptance and rejection, free-storage field +presence, and omission when unset or 0). Value-level errors (BadValue) +are in test_dbStats_errors.py. +""" + +import pytest +from bson import SON, Decimal128, Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertProperties, + assertSuccessPartial, +) +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +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 +from documentdb_tests.framework.property_checks import Eq, Exists, NotExists +from documentdb_tests.framework.test_constants import FLOAT_INFINITY, BsonType + +pytestmark = pytest.mark.admin + + +DBSTATS_VALUE_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="dbStats_value", + msg="dbStats should accept all BSON types for the command field value", + keyword="dbStats", + valid_types=list(BsonType), + ), +] + +VALUE_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(DBSTATS_VALUE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", VALUE_ACCEPTANCE_CASES) +def test_dbStats_accepts_any_value_type(collection, bson_type, sample_value, spec): + """Test dbStats accepts all BSON types for the command field value.""" + result = execute_command(collection, {"dbStats": sample_value}) + assertSuccessPartial( + result, + {"ok": 1.0, "db": collection.database.name}, + msg=f"dbStats should accept {bson_type.value} for the command field value", + ) + + +EDGE_CASE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="value_zero", + command={"dbStats": 0}, + checks={"ok": Eq(1.0)}, + msg="dbStats:0 should succeed", + ), + DiagnosticTestCase( + id="value_negative_one", + command={"dbStats": -1}, + checks={"ok": Eq(1.0)}, + msg="dbStats:-1 should succeed", + ), + DiagnosticTestCase( + id="value_infinity", + command={"dbStats": FLOAT_INFINITY}, + checks={"ok": Eq(1.0)}, + msg="dbStats:Infinity should succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EDGE_CASE_TESTS)) +def test_dbStats_accepts_value_edge_cases(collection, test): + """Test dbStats succeeds for specific numeric edge-case command values.""" + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +SCALE_TYPE_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="scale", + msg="scale should reject non-numeric types with TypeMismatch", + keyword="scale", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.DECIMAL: Decimal128("1024"), BsonType.LONG: Int64(1024)}, + ), +] + +SCALE_REJECTION_CASES = generate_bson_rejection_test_cases(SCALE_TYPE_PARAMS) +SCALE_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(SCALE_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SCALE_ACCEPTANCE_CASES) +def test_dbStats_scale_accepts_valid_type(collection, bson_type, sample_value, spec): + """Test dbStats accepts valid BSON types for the scale parameter.""" + result = execute_command(collection, {"dbStats": 1, "scale": sample_value}) + assertSuccessPartial(result, {"ok": 1.0}, msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SCALE_REJECTION_CASES) +def test_dbStats_scale_rejects_invalid_type(collection, bson_type, sample_value, spec): + """Test dbStats rejects non-numeric BSON types for the scale parameter with TypeMismatch.""" + result = execute_command(collection, {"dbStats": 1, "scale": sample_value}) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +SCALE_EDGE_CASES: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "double_truncates", + command={"dbStats": 1, "scale": 2.5}, + checks={"ok": Eq(1.0), "scaleFactor": Eq(Int64(2))}, + msg="Double scale should truncate toward zero", + ), + DiagnosticTestCase( + "double_1023_999_truncates", + command={"dbStats": 1, "scale": 1023.999}, + checks={"ok": Eq(1.0), "scaleFactor": Eq(Int64(1023))}, + msg="Double scale 1023.999 should truncate to 1023", + ), + DiagnosticTestCase( + "default_no_scale", + command={"dbStats": 1}, + checks={"ok": Eq(1.0), "scaleFactor": Eq(Int64(1))}, + msg="Omitting scale should default scaleFactor to 1", + ), + DiagnosticTestCase( + "duplicate_keys_last_valid", + command=SON([("dbStats", 1), ("scale", 1), ("scale", 1024)]), + checks={"ok": Eq(1.0), "scaleFactor": Eq(Int64(1024))}, + msg="Last duplicate scale value should win", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SCALE_EDGE_CASES)) +def test_dbStats_scale_edge_cases(collection, test): + """Test dbStats scale truncation and default behaviour.""" + result = execute_command(collection, test.command) + assertProperties(result, test.checks, raw_res=True, msg=test.msg) + + +FREE_STORAGE_TYPE_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="freeStorage", + msg="freeStorage should reject non-numeric, non-bool types with TypeMismatch", + keyword="freeStorage", + valid_types=[ + BsonType.BOOL, + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +FREE_STORAGE_REJECTION_CASES = generate_bson_rejection_test_cases(FREE_STORAGE_TYPE_PARAMS) +FREE_STORAGE_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(FREE_STORAGE_TYPE_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", FREE_STORAGE_ACCEPTANCE_CASES) +def test_dbStats_free_storage_accepts_valid_type(collection, bson_type, sample_value, spec): + """Test dbStats accepts valid BSON types for the freeStorage parameter.""" + result = execute_command(collection, {"dbStats": 1, "freeStorage": sample_value}) + assertSuccessPartial(result, {"ok": 1.0}, msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", FREE_STORAGE_REJECTION_CASES) +def test_dbStats_free_storage_rejects_invalid_type(collection, bson_type, sample_value, spec): + """Test dbStats rejects non-numeric, non-bool BSON types for freeStorage with TypeMismatch.""" + result = execute_command(collection, {"dbStats": 1, "freeStorage": sample_value}) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +FREE_STORAGE_FIELD_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "free_storage_one_includes_fields", + setup=[ + {"insert": "c1", "documents": [{"_id": 1}]}, + {"createIndexes": "c1", "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ], + command={"dbStats": 1, "freeStorage": 1}, + checks={ + "freeStorageSize": Exists(), + "indexFreeStorageSize": Exists(), + "totalFreeStorageSize": Exists(), + }, + msg="freeStorage:1 should include free-storage fields", + ), + DiagnosticTestCase( + "no_free_storage_param", + setup=[{"insert": "c1", "documents": [{"_id": 1}]}], + command={"dbStats": 1}, + checks={ + "freeStorageSize": NotExists(), + "indexFreeStorageSize": NotExists(), + "totalFreeStorageSize": NotExists(), + }, + msg="Omitting freeStorage should omit free-storage fields", + ), + DiagnosticTestCase( + "free_storage_zero", + setup=[{"insert": "c1", "documents": [{"_id": 1}]}], + command={"dbStats": 1, "freeStorage": 0}, + checks={ + "freeStorageSize": NotExists(), + "indexFreeStorageSize": NotExists(), + "totalFreeStorageSize": NotExists(), + }, + msg="freeStorage:0 should omit free-storage fields", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(FREE_STORAGE_FIELD_TESTS)) +def test_dbStats_free_storage_fields(collection, test): + """Test dbStats free-storage field presence based on the freeStorage option.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + result = execute_command(collection, test.command) + assertProperties(result, test.checks, raw_res=True, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_collection_scenarios.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_collection_scenarios.py new file mode 100644 index 000000000..e631c837e --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_collection_scenarios.py @@ -0,0 +1,69 @@ +"""Tests for dbStats across collection variants and data scenarios. + +Covers empty collections (with and without a secondary index), avgObjSize +when there are no objects, index counts across multiple collections, and +capped collections. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +COLLECTION_SCENARIO_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="empty_collection_index_count", + setup=[{"createIndexes": "c1", "indexes": [{"key": {"a": 1}, "name": "a_1"}]}], + command={"dbStats": 1}, + checks={"indexes": Eq(Int64(2))}, + msg="Empty collection with one secondary index should report indexes:2", + ), + DiagnosticTestCase( + id="avg_obj_size_zero_when_no_objects", + command={"dbStats": 1}, + checks={"objects": Eq(Int64(0)), "avgObjSize": Eq(0.0)}, + msg="Empty database should report objects:0 and avgObjSize:0", + ), + DiagnosticTestCase( + id="indexes_across_collections", + setup=[ + {"insert": "c1", "documents": [{"_id": i, "a": i} for i in range(5)]}, + {"createIndexes": "c1", "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + {"insert": "c2", "documents": [{"_id": i, "b": i} for i in range(5)]}, + {"createIndexes": "c2", "indexes": [{"key": {"b": 1}, "name": "b_1"}]}, + ], + command={"dbStats": 1}, + checks={"indexes": Eq(Int64(4))}, + msg="indexes should total default plus secondary indexes across collections", + ), + DiagnosticTestCase( + id="capped_collection_counted", + setup=[ + {"create": "c1", "capped": True, "size": 4096}, + {"insert": "c1", "documents": [{"_id": i} for i in range(3)]}, + ], + command={"dbStats": 1}, + checks={"collections": Eq(Int64(1)), "objects": Eq(Int64(3))}, + msg="Capped collection should be counted with its documents", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COLLECTION_SCENARIO_TESTS)) +def test_dbStats_collection_scenarios(collection, test): + """Test dbStats database-level counts and sizes across collection variants and data shapes.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_core_behavior.py new file mode 100644 index 000000000..3d75e21e6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_core_behavior.py @@ -0,0 +1,78 @@ +"""Tests for dbStats command core behavior. + +Covers success on populated and empty databases, the all-zero response for +a non-existent database, and execution against the admin database. +Command-level errors are in test_dbStats_errors.py. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType +from documentdb_tests.framework.target_collection import TargetDatabase + +pytestmark = pytest.mark.admin + + +SUCCESS_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="populated_database", + setup=[{"insert": "c1", "documents": [{"_id": 0, "a": 1}, {"_id": 1, "a": 2}]}], + command={"dbStats": 1}, + use_admin=False, + checks={"ok": Eq(1.0), "db": IsType("string")}, + msg="Populated database should return ok:1", + ), + DiagnosticTestCase( + id="empty_database", + command={"dbStats": 1}, + use_admin=False, + checks={"ok": Eq(1.0), "db": IsType("string"), "collections": Eq(Int64(0))}, + msg="Empty database should return ok:1 with zero collections", + ), + DiagnosticTestCase( + id="admin_database", + command={"dbStats": 1}, + use_admin=True, + checks={"ok": Eq(1.0), "db": Eq("admin")}, + msg="dbStats on admin database should report db:admin", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SUCCESS_TESTS)) +def test_dbStats_core_behavior(collection, test): + """Test dbStats succeeds and reports expected top-level fields across databases.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + executor = execute_admin_command if test.use_admin else execute_command + result = executor(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_dbStats_nonexistent_database_returns_zeros(collection, register_db_cleanup): + """Test dbStats on a non-existent database returns zeroed size and count fields.""" + missing_coll = TargetDatabase(suffix="missing").resolve(collection.database, collection) + register_db_cleanup(missing_coll.database.name) + result = execute_command(missing_coll, {"dbStats": 1}) + assertSuccessPartial( + result, + { + "ok": 1.0, + "db": missing_coll.database.name, + "collections": Int64(0), + "objects": Int64(0), + "storageSize": 0.0, + "indexes": Int64(0), + "indexSize": 0.0, + }, + msg="Non-existent database should report all counts and sizes as zero", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_errors.py new file mode 100644 index 000000000..05beb2174 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_errors.py @@ -0,0 +1,65 @@ +"""Tests for dbStats command error conditions. + +Covers value-level errors (BadValue for invalid scale values) and +command-level errors (unrecognized fields). Type-level rejections +(TypeMismatch for invalid scale types) are in test_dbStats_argument_handling.py. +""" + +import pytest +from bson import SON + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR, UNRECOGNIZED_COMMAND_FIELD_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + + +INVALID_SCALE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + "zero", + command={"dbStats": 1, "scale": 0}, + error_code=BAD_VALUE_ERROR, + msg="scale=0 should error with BadValue", + ), + DiagnosticTestCase( + "negative_int", + command={"dbStats": 1, "scale": -1}, + error_code=BAD_VALUE_ERROR, + msg="Negative int scale should error with BadValue", + ), + DiagnosticTestCase( + "fractional_lt_1", + command={"dbStats": 1, "scale": 0.5}, + error_code=BAD_VALUE_ERROR, + msg="Fractional scale < 1 should error with BadValue", + ), + DiagnosticTestCase( + "duplicate_keys_last_invalid", + command=SON([("dbStats", 1), ("scale", 1024), ("scale", -1)]), + error_code=BAD_VALUE_ERROR, + msg="Invalid last duplicate scale value should error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_SCALE_TESTS)) +def test_dbStats_invalid_scale_errors(collection, test): + """Test dbStats rejects invalid (non-positive/truncate-to-zero) scale values with BadValue.""" + result = execute_command(collection, test.command) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_dbStats_unrecognized_field_errors(collection): + """Test dbStats rejects an unrecognized command field.""" + collection.insert_one({"_id": 1}) + result = execute_command(collection, {"dbStats": 1, "bogusField": 1}) + assertFailureCode( + result, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="Unrecognized command field should error with code 40415", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_response_structure.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_response_structure.py new file mode 100644 index 000000000..25272aa74 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/dbStats/test_dbStats_response_structure.py @@ -0,0 +1,169 @@ +"""Tests for the dbStats response structure. + +Covers the presence and BSON type of every documented response field, the +totalSize relationship, dataSize positivity after inserts, the avgObjSize +relationship, and collection/view counts. +""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.system.diagnostic.utils.diagnostic_test_case import ( + DiagnosticTestCase, +) +from documentdb_tests.framework.assertions import assertProperties +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gt, IsType + +pytestmark = pytest.mark.admin + + +RESPONSE_PROPERTY_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="db_is_string", + checks={"db": IsType("string")}, + msg="'db' field should be a string", + ), + DiagnosticTestCase( + id="collections_is_long", + checks={"collections": IsType("long")}, + msg="'collections' field should be a long", + ), + DiagnosticTestCase( + id="views_is_long", + checks={"views": IsType("long")}, + msg="'views' field should be a long", + ), + DiagnosticTestCase( + id="objects_is_long", + checks={"objects": IsType("long")}, + msg="'objects' field should be a long", + ), + DiagnosticTestCase( + id="avgObjSize_is_double", + checks={"avgObjSize": IsType("double")}, + msg="'avgObjSize' field should be a double", + ), + DiagnosticTestCase( + id="dataSize_is_double", + checks={"dataSize": IsType("double")}, + msg="'dataSize' field should be a double", + ), + DiagnosticTestCase( + id="storageSize_is_double", + checks={"storageSize": IsType("double")}, + msg="'storageSize' field should be a double", + ), + DiagnosticTestCase( + id="indexes_is_long", + checks={"indexes": IsType("long")}, + msg="'indexes' field should be a long", + ), + DiagnosticTestCase( + id="indexSize_is_double", + checks={"indexSize": IsType("double")}, + msg="'indexSize' field should be a double", + ), + DiagnosticTestCase( + id="totalSize_is_double", + checks={"totalSize": IsType("double")}, + msg="'totalSize' field should be a double", + ), + DiagnosticTestCase( + id="scaleFactor_is_long", + checks={"scaleFactor": IsType("long")}, + msg="'scaleFactor' field should be a long", + ), + DiagnosticTestCase( + id="fsUsedSize_is_double", + checks={"fsUsedSize": IsType("double")}, + msg="'fsUsedSize' field should be a double", + ), + DiagnosticTestCase( + id="fsTotalSize_is_double", + checks={"fsTotalSize": IsType("double")}, + msg="'fsTotalSize' field should be a double", + ), + DiagnosticTestCase( + id="ok_is_double", + checks={"ok": IsType("double")}, + msg="'ok' field should be a double", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_PROPERTY_TESTS)) +def test_dbStats_response_properties(collection, test): + """Verifies each documented dbStats response field has the expected BSON type.""" + collection.insert_many([{"_id": i, "a": i} for i in range(5)]) + collection.create_index("a") + result = execute_command(collection, {"dbStats": 1}) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) + + +def test_dbStats_total_size_relationship(collection): + """Test totalSize equals storageSize plus indexSize.""" + collection.insert_many([{"_id": i, "a": i} for i in range(20)]) + collection.create_index("a") + result = execute_command(collection, {"dbStats": 1}) + assertProperties( + result, + {"totalSize": Eq(result.get("storageSize") + result.get("indexSize"))}, + raw_res=True, + msg="totalSize should equal storageSize + indexSize", + ) + + +def test_dbStats_avg_obj_size_equals_data_size_over_objects(collection): + """Test avgObjSize equals dataSize divided by objects.""" + collection.insert_many([{"_id": i, "data": "x" * (i + 1)} for i in range(10)]) + result = execute_command(collection, {"dbStats": 1}) + assertProperties( + result, + {"avgObjSize": Eq(result.get("dataSize") / result.get("objects"))}, + raw_res=True, + msg="avgObjSize should equal dataSize / objects", + ) + + +RESPONSE_STATE_TESTS: list[DiagnosticTestCase] = [ + DiagnosticTestCase( + id="data_size_positive_after_insert", + setup=[{"insert": "c1", "documents": [{"_id": i, "data": "x" * 50} for i in range(10)]}], + command={"dbStats": 1}, + checks={"dataSize": Gt(0.0)}, + msg="dataSize should be positive after inserts", + ), + DiagnosticTestCase( + id="collections_count_includes_system_views", + setup=[ + {"insert": "c1", "documents": [{"_id": i} for i in range(3)]}, + {"create": "c1_view", "viewOn": "c1", "pipeline": []}, + ], + command={"dbStats": 1}, + checks={"collections": Eq(Int64(2))}, + msg="collections should include the base collection and system.views", + ), + DiagnosticTestCase( + id="views_count", + setup=[ + {"insert": "c1", "documents": [{"_id": i} for i in range(3)]}, + {"create": "c1_view", "viewOn": "c1", "pipeline": []}, + ], + command={"dbStats": 1}, + checks={"views": Eq(Int64(1))}, + msg="views should count the created view", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_STATE_TESTS)) +def test_dbStats_response_reflects_state(collection, test): + """Test dbStats response fields reflect database state from setup commands.""" + for setup_command in test.setup: + setup_result = execute_command(collection, setup_command) + if isinstance(setup_result, Exception): + raise setup_result + result = execute_command(collection, test.command) + assertProperties(result, test.checks, msg=test.msg, raw_res=True) From 740bc4e1a7b4aece13b3a8809e7599e72aef36d0 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 7 Jul 2026 13:12:54 -0700 Subject: [PATCH 51/51] Add $exp expression tests (#663) Signed-off-by: Daniel Frankcom --- .../arithmetic/exp/test_exp_boundaries.py | 166 ++++++++++++++++++ .../arithmetic/exp/test_exp_errors.py | 76 ++++++++ .../arithmetic/exp/test_exp_input_forms.py | 97 ++++++++++ .../arithmetic/exp/test_exp_magnitude.py | 140 +++++++++++++++ .../arithmetic/exp/test_exp_non_finite.py | 86 +++++++++ .../arithmetic/exp/test_exp_null.py | 45 +++++ .../arithmetic/exp/test_exp_return_type.py | 58 ++++++ 7 files changed, 668 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_magnitude.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_return_type.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_boundaries.py new file mode 100644 index 000000000..f472fcd13 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_boundaries.py @@ -0,0 +1,166 @@ +"""Tests for $exp at representable-range boundaries, including overflow and underflow.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_SMALL_EXPONENT, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_ZERO, + FLOAT_INFINITY, + INT32_MAX, + INT64_MAX, + INT64_MIN, +) + +# Property [Overflow]: $exp overflows to infinity past the largest representable exponent and +# underflows to zero for large negative inputs. +EXP_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "just_below_overflow", + doc={"value": 709}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(8.218407461554972e307), + msg="$exp should return a large finite double just below the overflow boundary", + ), + ExpressionTestCase( + "overflow", + doc={"value": 710}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should return infinity past the overflow boundary", + ), + ExpressionTestCase( + "underflow", + doc={"value": -750}, + expression={"$exp": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$exp should underflow to zero for a large negative input", + ), +] + +# Property [Integer Boundaries]: $exp of extreme integer inputs overflows to infinity or +# underflows to zero. +EXP_INTEGER_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"value": INT32_MAX}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should return infinity for INT32_MAX", + ), + ExpressionTestCase( + "int64_max", + doc={"value": INT64_MAX}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should return infinity for INT64_MAX", + ), + ExpressionTestCase( + "int64_min", + doc={"value": INT64_MIN}, + expression={"$exp": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$exp should underflow to zero for INT64_MIN", + ), +] + +# Property [Double Boundaries]: $exp near the double subnormal range returns one, and a large +# double overflows to infinity. +EXP_DOUBLE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_min_subnormal", + doc={"value": DOUBLE_MIN_SUBNORMAL}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for the minimum subnormal double", + ), + ExpressionTestCase( + "double_min_negative_subnormal", + doc={"value": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for the minimum negative subnormal double", + ), + ExpressionTestCase( + "double_near_min", + doc={"value": DOUBLE_NEAR_MIN}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for a near-zero positive double", + ), + ExpressionTestCase( + "double_near_max", + doc={"value": DOUBLE_NEAR_MAX}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should overflow to infinity for a large double", + ), +] + +# Property [Decimal128 Boundaries]: $exp of extreme decimal128 exponents overflows to infinity or +# underflows to zero, preserving the decimal type. +EXP_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_small_exponent", + doc={"value": DECIMAL128_SMALL_EXPONENT}, + expression={"$exp": ["$value"]}, + expected=Decimal128("1"), + msg="$exp should return one for a decimal128 with a small exponent", + ), + ExpressionTestCase( + "decimal_large_exponent", + doc={"value": DECIMAL128_LARGE_EXPONENT}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$exp should overflow to infinity for a decimal128 with a large exponent", + ), + ExpressionTestCase( + "decimal_max", + doc={"value": DECIMAL128_MAX}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$exp should overflow to infinity for decimal128 max", + ), + ExpressionTestCase( + "decimal_min", + doc={"value": DECIMAL128_MIN}, + expression={"$exp": ["$value"]}, + expected=Decimal128("0E-6176"), + msg="$exp should underflow to zero for decimal128 min", + ), +] + +EXP_BOUNDARY_ALL_TESTS = ( + EXP_OVERFLOW_TESTS + + EXP_INTEGER_BOUNDARY_TESTS + + EXP_DOUBLE_BOUNDARY_TESTS + + EXP_DECIMAL_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_BOUNDARY_ALL_TESTS)) +def test_exp_boundaries(collection, test_case: ExpressionTestCase): + """Test $exp representable-range boundary cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_errors.py new file mode 100644 index 000000000..00aebadd6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_errors.py @@ -0,0 +1,76 @@ +"""Tests for $exp type and arity errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + NON_NUMERIC_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Type Strictness]: $exp rejects non-numeric input types. +EXP_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"type_{tid}", + doc={"value": val}, + expression={"$exp": ["$value"]}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg=f"$exp should reject a {tid} input", + ) + for tid, val in [ + ("string", "abc"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("regex", Regex("abc")), + ("binary", Binary(b"data")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ] +] + +# Property [Arity]: $exp requires exactly one argument. +EXP_ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_zero", + doc={}, + expression={"$exp": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$exp should reject zero arguments", + ), + ExpressionTestCase( + "arity_two", + doc={}, + expression={"$exp": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$exp should reject two arguments", + ), +] + +EXP_ERROR_ALL_TESTS = EXP_TYPE_ERROR_TESTS + EXP_ARITY_ERROR_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_ERROR_ALL_TESTS)) +def test_exp_errors(collection, test_case: ExpressionTestCase): + """Test $exp type and arity error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_input_forms.py new file mode 100644 index 000000000..729d7713b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_input_forms.py @@ -0,0 +1,97 @@ +"""Tests for $exp argument forms, literal input, field paths, and nested expression input.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import NON_NUMERIC_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Form]: $exp accepts its single argument bare or wrapped in a one-element +# array. +EXP_ARGUMENT_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "form_array", + doc={"value": 1}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(2.718281828459045), + msg="$exp should accept its argument wrapped in a one-element array", + ), + ExpressionTestCase( + "form_bare", + doc={"value": 1}, + expression={"$exp": "$value"}, + expected=pytest.approx(2.718281828459045), + msg="$exp should accept its argument without an array wrapper", + ), +] + +# Property [Literal Input]: $exp evaluates an inline literal argument, not only document fields. +EXP_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_input", + doc={}, + expression={"$exp": [1]}, + expected=pytest.approx(2.718281828459045), + msg="$exp should return e for an inline literal argument", + ), +] + +# Property [Expression Input]: $exp evaluates a nested expression argument before exponentiating. +EXP_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_exp", + doc={}, + expression={"$exp": {"$exp": 1}}, + expected=pytest.approx(15.154262241479262), + msg="$exp should evaluate a nested $exp expression argument", + ), +] + +# Property [Field Path Input]: $exp resolves a field path argument. A dotted path into a nested +# object yields the referenced value; a path over an array (an array-of-objects path, or a numeric +# component applied over an array) resolves to an array, which $exp rejects as a non-numeric type. +EXP_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": 1}}, + expression={"$exp": "$a.b"}, + expected=pytest.approx(2.718281828459045), + msg="$exp should resolve a dotted field path into a nested object", + ), + ExpressionTestCase( + "composite_array_field_path", + doc={"a": [{"b": 1}, {"b": 2}]}, + expression={"$exp": "$a.b"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$exp should reject a field path that resolves to an array from an array of objects", + ), + ExpressionTestCase( + "array_index_field_path", + doc={"a": [1, 2]}, + expression={"$exp": "$a.0"}, + error_code=NON_NUMERIC_TYPE_MISMATCH_ERROR, + msg="$exp should reject a numeric path component over an array, which resolves non-numeric", + ), +] + +EXP_INPUT_FORM_TESTS = ( + EXP_ARGUMENT_FORM_TESTS + EXP_LITERAL_TESTS + EXP_EXPRESSION_INPUT_TESTS + EXP_FIELD_PATH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_INPUT_FORM_TESTS)) +def test_exp_input_forms(collection, test_case: ExpressionTestCase): + """Test $exp argument form, literal, field path, and nested expression input cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_magnitude.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_magnitude.py new file mode 100644 index 000000000..204537099 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_magnitude.py @@ -0,0 +1,140 @@ +"""Tests for $exp core exponentiation values, including signed zero.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT64_ZERO, +) + +# Property [Exponent Value]: $exp returns e raised to the input power. +EXP_VALUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_int32", + doc={"value": 1}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(2.718281828459045), + msg="$exp should return e for int32 one", + ), + ExpressionTestCase( + "positive_int64", + doc={"value": Int64(1)}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(2.718281828459045), + msg="$exp should return e for int64 one", + ), + ExpressionTestCase( + "positive_double", + doc={"value": 1.5}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(4.4816890703380645), + msg="$exp should return e raised to 1.5 for double 1.5", + ), + ExpressionTestCase( + "positive_decimal", + doc={"value": Decimal128("1")}, + expression={"$exp": ["$value"]}, + expected=Decimal128("2.718281828459045235360287471352662"), + msg="$exp should return e for decimal128 one", + ), + ExpressionTestCase( + "negative_int32", + doc={"value": -1}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(0.36787944117144233), + msg="$exp should return e raised to -1 for int32 negative one", + ), + ExpressionTestCase( + "negative_int64", + doc={"value": Int64(-1)}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(0.36787944117144233), + msg="$exp should return e raised to -1 for int64 negative one", + ), + ExpressionTestCase( + "negative_double", + doc={"value": -1.5}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(0.22313016014842982), + msg="$exp should return e raised to -1.5 for double -1.5", + ), + ExpressionTestCase( + "negative_decimal", + doc={"value": Decimal128("-1")}, + expression={"$exp": ["$value"]}, + expected=Decimal128("0.3678794411714423215955237701614609"), + msg="$exp should return e raised to -1 for decimal128 negative one", + ), +] + +# Property [Zero]: $exp of any zero, including negative zero, returns one. +EXP_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_int32", + doc={"value": 0}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for int32 zero", + ), + ExpressionTestCase( + "zero_int64", + doc={"value": INT64_ZERO}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for int64 zero", + ), + ExpressionTestCase( + "zero_double", + doc={"value": DOUBLE_ZERO}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for double zero", + ), + ExpressionTestCase( + "negative_zero_double", + doc={"value": DOUBLE_NEGATIVE_ZERO}, + expression={"$exp": ["$value"]}, + expected=1.0, + msg="$exp should return one for negative zero double", + ), + ExpressionTestCase( + "zero_decimal", + doc={"value": DECIMAL128_ZERO}, + expression={"$exp": ["$value"]}, + expected=Decimal128("1"), + msg="$exp should return one for decimal128 zero", + ), + ExpressionTestCase( + "negative_zero_decimal", + doc={"value": DECIMAL128_NEGATIVE_ZERO}, + expression={"$exp": ["$value"]}, + expected=Decimal128("1"), + msg="$exp should return one for negative zero decimal128", + ), +] + +EXP_MAGNITUDE_ALL_TESTS = EXP_VALUE_TESTS + EXP_ZERO_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_MAGNITUDE_ALL_TESTS)) +def test_exp_magnitude(collection, test_case: ExpressionTestCase): + """Test $exp exponentiation value and signed-zero cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_non_finite.py new file mode 100644 index 000000000..c5cf04512 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_non_finite.py @@ -0,0 +1,86 @@ +"""Tests for $exp of infinity and NaN inputs.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: $exp of positive infinity is infinity and of negative infinity is zero. +EXP_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_infinity", + doc={"value": FLOAT_INFINITY}, + expression={"$exp": ["$value"]}, + expected=FLOAT_INFINITY, + msg="$exp should return infinity for float infinity", + ), + ExpressionTestCase( + "float_negative_infinity", + doc={"value": FLOAT_NEGATIVE_INFINITY}, + expression={"$exp": ["$value"]}, + expected=DOUBLE_ZERO, + msg="$exp should return zero for float negative infinity", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"value": DECIMAL128_INFINITY}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_INFINITY, + msg="$exp should return decimal128 infinity for decimal128 infinity", + ), + ExpressionTestCase( + "decimal128_negative_infinity", + doc={"value": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_ZERO, + msg="$exp should return decimal128 zero for decimal128 negative infinity", + ), +] + +# Property [NaN]: $exp of NaN returns NaN of the same type. +EXP_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan", + doc={"value": FLOAT_NAN}, + expression={"$exp": ["$value"]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$exp should return NaN for float NaN", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"value": DECIMAL128_NAN}, + expression={"$exp": ["$value"]}, + expected=DECIMAL128_NAN, + msg="$exp should return NaN for decimal128 NaN", + ), +] + +EXP_NON_FINITE_TESTS = EXP_INFINITY_TESTS + EXP_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_NON_FINITE_TESTS)) +def test_exp_non_finite(collection, test_case: ExpressionTestCase): + """Test $exp infinity and NaN cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_null.py new file mode 100644 index 000000000..180aea15f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_null.py @@ -0,0 +1,45 @@ +"""Tests for $exp null and missing field propagation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Propagation]: $exp of null or a missing field returns null. +EXP_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value", + doc={"value": None}, + expression={"$exp": ["$value"]}, + expected=None, + msg="$exp should return null for null input", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$exp": [MISSING]}, + expected=None, + msg="$exp should return null for a missing field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_NULL_TESTS)) +def test_exp_null(collection, test_case: ExpressionTestCase): + """Test $exp null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_return_type.py new file mode 100644 index 000000000..e54827dee --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/exp/test_exp_return_type.py @@ -0,0 +1,58 @@ +"""Tests for $exp return type across numeric input types.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $exp returns double for int32, int64, and double inputs, and decimal for +# decimal128 inputs. +EXP_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int32", + doc={"value": 1}, + expression={"$type": {"$exp": "$value"}}, + expected="double", + msg="$exp should return a double for an int32 input", + ), + ExpressionTestCase( + "return_type_int64", + doc={"value": Int64(1)}, + expression={"$type": {"$exp": "$value"}}, + expected="double", + msg="$exp should return a double for an int64 input", + ), + ExpressionTestCase( + "return_type_double", + doc={"value": 1.0}, + expression={"$type": {"$exp": "$value"}}, + expected="double", + msg="$exp should return a double for a double input", + ), + ExpressionTestCase( + "return_type_decimal", + doc={"value": Decimal128("1")}, + expression={"$type": {"$exp": "$value"}}, + expected="decimal", + msg="$exp should return a decimal for a decimal128 input", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(EXP_RETURN_TYPE_TESTS)) +def test_exp_return_type(collection, test_case: ExpressionTestCase): + """Test $exp return type cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + )