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..f8cad546f --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/commands/hello/test_hello_consistency.py @@ -0,0 +1,233 @@ +"""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. +def test_hello_standalone_rs_fields_absent(collection): + """Test hello does not return replica set fields on standalone.""" + result = execute_command(collection, {"hello": 1}) + if result.get("setName"): + pytest.skip("connected to replica set, not standalone") + 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..692729df3 --- /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.replica_set] + +# 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/__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..449e89bfa --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/replication/utils/replication_test_case.py @@ -0,0 +1,49 @@ +"""Shared test case for replication command tests.""" + +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, +) + + +@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. + + 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 + against the test database via ``execute_command``. + """ + + 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 0ffb575cf..26c53a3d3 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.