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..8373e1286 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_command_interaction.py @@ -0,0 +1,265 @@ +""" +readConcern command interaction tests. + +Verifies that readConcern works correctly with other command options +(sort, projection, limit, skip, query filters) and on empty/non-existent collections. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.query_and_write.read_concern.utils import ( + is_cursor_command, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Command Option Interaction]: readConcern does not interfere with other command options. +INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_with_sort", + docs=[{"_id": 1, "x": 3}, {"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "sort": {"x": 1}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 2, "x": 1}, {"_id": 3, "x": 2}, {"_id": 1, "x": 3}], + msg="find with readConcern should respect sort order.", + ), + CommandTestCase( + "find_with_projection", + docs=[{"_id": 1, "x": 1, "y": 2}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "projection": {"x": 1, "_id": 0}, + "readConcern": {"level": "local"}, + }, + expected=[{"x": 1}], + msg="find with readConcern should respect projection.", + ), + CommandTestCase( + "find_with_limit", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "limit": 2, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 1}, {"_id": 2}], + msg="find with readConcern should respect limit.", + ), + CommandTestCase( + "find_with_skip", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "skip": 1, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 2}, {"_id": 3}], + msg="find with readConcern should respect skip.", + ), + CommandTestCase( + "find_with_expr_filter", + docs=[{"_id": 1, "a": 5, "b": 3}, {"_id": 2, "a": 2, "b": 4}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"$expr": {"$gt": ["$a", "$b"]}}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 1, "a": 5, "b": 3}], + msg="find with readConcern should support $expr filter.", + ), + CommandTestCase( + "aggregate_with_multiple_stages", + docs=[ + {"_id": 1, "x": 1, "g": "a"}, + {"_id": 2, "x": 2, "g": "a"}, + {"_id": 3, "x": 3, "g": "b"}, + ], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"g": "a"}}, {"$sort": {"x": -1}}, {"$limit": 1}], + "cursor": {}, + "readConcern": {"level": "majority"}, + }, + expected=[{"_id": 2, "x": 2, "g": "a"}], + msg="aggregate with readConcern should work with multiple stages.", + ), + CommandTestCase( + "distinct_with_query", + docs=[ + {"_id": 1, "x": 1, "active": True}, + {"_id": 2, "x": 2, "active": False}, + {"_id": 3, "x": 1, "active": True}, + ], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + "query": {"active": True}, + "readConcern": {"level": "local"}, + }, + expected={"ok": 1.0, "values": [1]}, + msg="distinct with readConcern should respect query filter.", + ), + CommandTestCase( + "count_with_query", + docs=[{"_id": 1, "s": "a"}, {"_id": 2, "s": "b"}, {"_id": 3, "s": "a"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "a"}, + "readConcern": {"level": "available"}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with readConcern should respect query filter.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INTERACTION_TESTS)) +def test_read_concern_command_interaction(collection, test: CommandTestCase): + """Test readConcern works correctly with other command options.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +# Property [Empty Collection]: readConcern on an empty collection returns empty results. +EMPTY_COLLECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_on_empty_collection", + docs=[], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "readConcern": {"level": "local"}, + }, + expected=[], + msg="find with readConcern on empty collection should return empty.", + ), + CommandTestCase( + "aggregate_on_empty_collection", + docs=[], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {}}], + "cursor": {}, + "readConcern": {"level": "majority"}, + }, + expected=[], + msg="aggregate with readConcern on empty collection should return empty.", + ), + CommandTestCase( + "count_on_empty_collection", + docs=[], + command=lambda ctx: { + "count": ctx.collection, + "query": {}, + "readConcern": {"level": "local"}, + }, + expected={"n": 0, "ok": 1.0}, + msg="count with readConcern on empty collection should return zero.", + ), + CommandTestCase( + "distinct_on_empty_collection", + docs=[], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + "readConcern": {"level": "local"}, + }, + expected={"ok": 1.0, "values": []}, + msg="distinct with readConcern on empty collection should return empty values.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EMPTY_COLLECTION_TESTS)) +def test_read_concern_on_empty_collection(collection, test: CommandTestCase): + """Test readConcern on an empty collection returns empty results.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +# Property [Non-Existent Collection]: non-existent collections return empty results. +def test_find_read_concern_on_nonexistent_collection(database_client): + """Test find with readConcern on non-existent collection returns empty.""" + coll = database_client["nonexistent_rc_test_coll"] + result = execute_command( + coll, + {"find": coll.name, "filter": {}, "readConcern": {"level": "local"}}, + ) + assertSuccess( + result, [], msg="find with readConcern on non-existent collection should return empty." + ) + + +def test_count_read_concern_on_nonexistent_collection(database_client): + """Test count with readConcern on non-existent collection returns zero.""" + coll = database_client["nonexistent_rc_test_coll"] + result = execute_command( + coll, + {"count": coll.name, "query": {}, "readConcern": {"level": "local"}}, + ) + assertResult( + result, + expected={"n": 0, "ok": 1.0}, + msg="count with readConcern on non-existent collection should return zero.", + raw_res=True, + ) + + +def test_aggregate_read_concern_on_nonexistent_collection(database_client): + """Test aggregate with readConcern on non-existent collection returns empty.""" + coll = database_client["nonexistent_rc_test_coll"] + result = execute_command( + coll, + { + "aggregate": coll.name, + "pipeline": [], + "cursor": {}, + "readConcern": {"level": "local"}, + }, + ) + assertSuccess( + result, [], msg="aggregate with readConcern on non-existent collection should return empty." + ) + + +def test_distinct_read_concern_on_nonexistent_collection(database_client): + """Test distinct with readConcern on non-existent collection returns empty values.""" + coll = database_client["nonexistent_rc_test_coll"] + result = execute_command( + coll, + {"distinct": coll.name, "key": "x", "readConcern": {"level": "local"}}, + ) + assertResult( + result, + expected={"ok": 1.0, "values": []}, + msg="distinct with readConcern on non-existent collection should return empty values.", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_cursor.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_cursor.py new file mode 100644 index 000000000..305f2b93b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_cursor.py @@ -0,0 +1,64 @@ +""" +readConcern with cursor continuation (getMore) tests. + +Verifies that readConcern is applied to the initial query and that getMore +continues correctly after a readConcern query. +""" + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +def test_getmore_after_find_with_read_concern_first_batch(collection): + """Test find with readConcern returns correct first batch with small batchSize.""" + docs = [{"_id": i, "x": i} for i in range(10)] + collection.insert_many(docs) + + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "batchSize": 3, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + ) + assertSuccess( + result, + [{"_id": 0, "x": 0}, {"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="First batch from find with readConcern should contain 3 documents.", + ) + + +def test_getmore_after_find_with_read_concern_next_batch(collection): + """Test getMore after find with readConcern returns next batch correctly.""" + docs = [{"_id": i, "x": i} for i in range(10)] + collection.insert_many(docs) + + # Get cursor from initial find. + initial_result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "batchSize": 3, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + ) + cursor_id = initial_result["cursor"]["id"] + + # getMore to fetch next batch. + getmore_result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 3}, + ) + # nextBatch is the field for getMore results. + next_batch = getmore_result["cursor"]["nextBatch"] + expected_next = [{"_id": 3, "x": 3}, {"_id": 4, "x": 4}, {"_id": 5, "x": 5}] + assertSuccess( + {"cursor": {"firstBatch": next_batch}}, + expected_next, + msg="getMore after readConcern find should return next batch correctly.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_field_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_field_validation.py new file mode 100644 index 000000000..e1fa106a9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_field_validation.py @@ -0,0 +1,356 @@ +""" +readConcern field validation tests. + +Tests type validation for the readConcern field and readConcern.level sub-field, +including invalid level strings, null coercion, and unknown extra fields. +""" + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, Regex + +from documentdb_tests.compatibility.tests.core.query_and_write.read_concern.utils import ( + is_cursor_command, +) +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 ( + BAD_VALUE_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 DATE_EPOCH, OID_EPOCH, TS_EPOCH + + +def _rc_find(ctx, rc): + return {"find": ctx.collection, "filter": {}, "readConcern": rc} + + +def _rc_aggregate(ctx, rc): + return {"aggregate": ctx.collection, "pipeline": [], "cursor": {}, "readConcern": rc} + + +def _rc_count(ctx, rc): + return {"count": ctx.collection, "query": {}, "readConcern": rc} + + +def _rc_distinct(ctx, rc): + return {"distinct": ctx.collection, "key": "x", "readConcern": rc} + + +# Property [Non-Document Rejection]: readConcern field rejects non-document types with TypeMismatch. +_NON_DOCUMENT_TYPES = [ + ("int", 1), + ("double", 1.0), + ("string", "local"), + ("bool", True), + ("array", [{"level": "local"}]), + ("int64", Int64(1)), + ("decimal128", Decimal128("1")), + ("objectId", OID_EPOCH), + ("date", DATE_EPOCH), + ("regex", Regex(".*")), + ("timestamp", TS_EPOCH), + ("binary", Binary(b"\x01")), + ("code", Code("function(){}")), + ("minKey", MinKey()), + ("maxKey", MaxKey()), +] + +NON_DOCUMENT_READ_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_rejects_non_document_read_concern_{type_name}", + command=lambda ctx, _rc=value, _cmd=cmd: { + _cmd: ctx.collection, + **(_build_extra_fields(_cmd)), + "readConcern": _rc, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"{cmd} should reject readConcern of type {type_name}.", + ) + for cmd in ["find", "aggregate", "count", "distinct"] + for type_name, value in _NON_DOCUMENT_TYPES +] + + +def _build_extra_fields(cmd: str) -> dict: + """Return extra fields needed per command type.""" + if cmd == "find": + return {"filter": {}} + elif cmd == "aggregate": + return {"pipeline": [], "cursor": {}} + elif cmd == "count": + return {"query": {}} + elif cmd == "distinct": + return {"key": "x"} + return {} + + +@pytest.mark.parametrize("test", pytest_params(NON_DOCUMENT_READ_CONCERN_TESTS)) +def test_read_concern_rejects_non_document(collection, test: CommandTestCase): + """Test readConcern rejects non-document types.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +# Property [Invalid Level Type Rejection]: readConcern.level rejects non-string types. +_INVALID_LEVEL_TYPES = [ + ("int", 1), + ("double", 1.0), + ("bool", True), + ("array", ["local"]), + ("document", {"value": "local"}), + ("int64", Int64(1)), + ("decimal128", Decimal128("1")), + ("objectId", OID_EPOCH), + ("date", DATE_EPOCH), + ("regex", Regex(".*")), + ("timestamp", TS_EPOCH), + ("binary", Binary(b"\x01")), + ("code", Code("function(){}")), + ("minKey", MinKey()), + ("maxKey", MaxKey()), +] + +INVALID_LEVEL_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_rejects_non_string_level_{type_name}", + command=lambda ctx, _rc={"level": value}, _cmd=cmd: { + _cmd: ctx.collection, + **(_build_extra_fields(_cmd)), + "readConcern": _rc, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"{cmd} should reject readConcern level of type {type_name}.", + ) + for cmd in ["find", "aggregate", "count", "distinct"] + for type_name, value in _INVALID_LEVEL_TYPES +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_LEVEL_TYPE_TESTS)) +def test_read_concern_rejects_non_string_level(collection, test: CommandTestCase): + """Test readConcern.level rejects non-string types.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +# Property [Invalid Level String Rejection]: unknown or improperly cased levels are rejected. +_INVALID_LEVEL_STRINGS = [ + ("empty_string", ""), + ("invalid", "invalid"), + ("uppercase_local", "LOCAL"), + ("mixed_case_majority", "Majority"), + ("nonexistent_strong", "strong"), +] + +INVALID_LEVEL_STRING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{cmd}_rejects_invalid_level_string_{str_name}", + command=lambda ctx, _rc={"level": value}, _cmd=cmd: { + _cmd: ctx.collection, + **(_build_extra_fields(_cmd)), + "readConcern": _rc, + }, + error_code=BAD_VALUE_ERROR, + msg=f"{cmd} should reject invalid readConcern level string '{value}'.", + ) + for cmd in ["find", "aggregate", "count", "distinct"] + for str_name, value in _INVALID_LEVEL_STRINGS +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_LEVEL_STRING_TESTS)) +def test_read_concern_rejects_invalid_level_string(collection, test: CommandTestCase): + """Test readConcern.level rejects invalid string values.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + + +# Property [Null readConcern Behavior]: readConcern field set to null is treated as omitted. +NULL_READ_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_null_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: {"find": ctx.collection, "filter": {}, "readConcern": None}, + expected=[{"_id": 1, "x": 1}], + msg="find should treat null readConcern as omitted.", + ), + CommandTestCase( + "aggregate_null_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [], + "cursor": {}, + "readConcern": None, + }, + expected=[{"_id": 1, "x": 1}], + msg="aggregate should treat null readConcern as omitted.", + ), + CommandTestCase( + "count_null_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: {"count": ctx.collection, "query": {}, "readConcern": None}, + expected={"n": 1, "ok": 1.0}, + msg="count should treat null readConcern as omitted.", + ), + CommandTestCase( + "distinct_null_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: {"distinct": ctx.collection, "key": "x", "readConcern": None}, + expected={"ok": 1.0, "values": [1]}, + msg="distinct should treat null readConcern as omitted.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NULL_READ_CONCERN_TESTS)) +def test_read_concern_null_behavior(collection, test: CommandTestCase): + """Test readConcern set to null is treated as omitted.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +# Property [Null Level As Default]: readConcern {level: null} uses the implicit default. +NULL_LEVEL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_null_level_treated_as_default", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: {"find": ctx.collection, "filter": {}, "readConcern": {"level": None}}, + expected=[{"_id": 1, "x": 1}], + msg="find should treat readConcern {level: null} as implicit default.", + ), + CommandTestCase( + "aggregate_null_level_treated_as_default", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [], + "cursor": {}, + "readConcern": {"level": None}, + }, + expected=[{"_id": 1, "x": 1}], + msg="aggregate should treat readConcern {level: null} as implicit default.", + ), + CommandTestCase( + "count_null_level_treated_as_default", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "count": ctx.collection, + "query": {}, + "readConcern": {"level": None}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should treat readConcern {level: null} as implicit default.", + ), + CommandTestCase( + "distinct_null_level_treated_as_default", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + "readConcern": {"level": None}, + }, + expected={"ok": 1.0, "values": [1]}, + msg="distinct should treat readConcern {level: null} as implicit default.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NULL_LEVEL_TESTS)) +def test_read_concern_null_level_treated_as_default(collection, test: CommandTestCase): + """Test readConcern {level: null} is treated as implicit default.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +# Property [Null Byte In Level String]: readConcern level with embedded null byte is rejected. +def test_aggregate_rejects_null_byte_in_level(collection): + """Test aggregate rejects readConcern level string containing null byte.""" + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [], + "cursor": {}, + "readConcern": {"level": "local\x00extra"}, + }, + ) + assertResult( + result, error_code=BAD_VALUE_ERROR, msg="aggregate should reject null byte in level string." + ) + + +# Property [Unknown Fields]: readConcern document with unknown fields and no level is rejected. +_UNKNOWN_FIELD_PARAMS = [ + pytest.param("find", id="find_unknown_field_no_level"), + pytest.param("aggregate", id="aggregate_unknown_field_no_level"), + pytest.param("count", id="count_unknown_field_no_level"), + pytest.param("distinct", id="distinct_unknown_field_no_level"), +] + + +@pytest.mark.parametrize("command_name", _UNKNOWN_FIELD_PARAMS) +def test_read_concern_unknown_field_no_level(collection, command_name): + """Test readConcern with unknown field and no level field returns error.""" + cmd = { + command_name: collection.name, + **_build_extra_fields(command_name), + "readConcern": {"unknownField": 1}, + } + result = execute_command(collection, cmd) + assertFailureCode( + result, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg=f"{command_name} should reject readConcern with unknown field and no level.", + ) + + +# Property [Extra Fields With Valid Level]: extra unknown fields with valid level are rejected. +_EXTRA_FIELD_PARAMS = [ + pytest.param("find", id="find_extra_field_with_level"), + pytest.param("aggregate", id="aggregate_extra_field_with_level"), + pytest.param("count", id="count_extra_field_with_level"), + pytest.param("distinct", id="distinct_extra_field_with_level"), +] + + +@pytest.mark.parametrize("command_name", _EXTRA_FIELD_PARAMS) +def test_read_concern_extra_field_with_valid_level(collection, command_name): + """Test readConcern with valid level plus unknown extra field.""" + cmd = { + command_name: collection.name, + **_build_extra_fields(command_name), + "readConcern": {"level": "local", "unknownField": 1}, + } + result = execute_command(collection, cmd) + assertFailureCode( + result, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg=f"{command_name} should reject readConcern with extra unknown field.", + ) 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..4372756b3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_level_acceptance.py @@ -0,0 +1,254 @@ +""" +readConcern level acceptance tests. + +Verifies that each valid readConcern level is accepted by each read command +and returns correct results. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.query_and_write.read_concern.utils import ( + is_cursor_command, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Level Acceptance]: read commands accept valid readConcern levels. +LEVEL_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_accepts_local", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="find should accept readConcern level 'local'.", + ), + CommandTestCase( + "find_accepts_available", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "sort": {"_id": 1}, + "readConcern": {"level": "available"}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="find should accept readConcern level 'available'.", + ), + CommandTestCase( + "find_accepts_majority", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "sort": {"_id": 1}, + "readConcern": {"level": "majority"}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="find should accept readConcern level 'majority'.", + ), + CommandTestCase( + "aggregate_accepts_local", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$sort": {"_id": 1}}], + "cursor": {}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="aggregate should accept readConcern level 'local'.", + ), + CommandTestCase( + "aggregate_accepts_available", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$sort": {"_id": 1}}], + "cursor": {}, + "readConcern": {"level": "available"}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="aggregate should accept readConcern level 'available'.", + ), + CommandTestCase( + "aggregate_accepts_majority", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$sort": {"_id": 1}}], + "cursor": {}, + "readConcern": {"level": "majority"}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="aggregate should accept readConcern level 'majority'.", + ), + CommandTestCase( + "count_accepts_local", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "count": ctx.collection, + "query": {}, + "readConcern": {"level": "local"}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count should accept readConcern level 'local'.", + ), + CommandTestCase( + "count_accepts_available", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "count": ctx.collection, + "query": {}, + "readConcern": {"level": "available"}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count should accept readConcern level 'available'.", + ), + CommandTestCase( + "count_accepts_majority", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "count": ctx.collection, + "query": {}, + "readConcern": {"level": "majority"}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count should accept readConcern level 'majority'.", + ), + CommandTestCase( + "distinct_accepts_local", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + "readConcern": {"level": "local"}, + }, + expected={"ok": 1.0, "values": [1, 2]}, + msg="distinct should accept readConcern level 'local'.", + ), + CommandTestCase( + "distinct_accepts_available", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + "readConcern": {"level": "available"}, + }, + expected={"ok": 1.0, "values": [1, 2]}, + msg="distinct should accept readConcern level 'available'.", + ), + CommandTestCase( + "distinct_accepts_majority", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + "readConcern": {"level": "majority"}, + }, + expected={"ok": 1.0, "values": [1, 2]}, + msg="distinct should accept readConcern level 'majority'.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LEVEL_ACCEPTANCE_TESTS)) +def test_read_concern_level_acceptance(collection, test: CommandTestCase): + """Test readConcern level is accepted by the command.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +# Property [Empty readConcern Document]: empty readConcern uses the implicit default. +EMPTY_READ_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_accepts_empty_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: {"find": ctx.collection, "filter": {}, "readConcern": {}}, + expected=[{"_id": 1, "x": 1}], + msg="find should accept empty readConcern document.", + ), + CommandTestCase( + "aggregate_accepts_empty_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [], + "cursor": {}, + "readConcern": {}, + }, + expected=[{"_id": 1, "x": 1}], + msg="aggregate should accept empty readConcern document.", + ), + CommandTestCase( + "count_accepts_empty_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: {"count": ctx.collection, "query": {}, "readConcern": {}}, + expected={"n": 1, "ok": 1.0}, + msg="count should accept empty readConcern document.", + ), + CommandTestCase( + "distinct_accepts_empty_read_concern", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: {"distinct": ctx.collection, "key": "x", "readConcern": {}}, + expected={"ok": 1.0, "values": [1]}, + msg="distinct should accept empty readConcern document.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EMPTY_READ_CONCERN_TESTS)) +def test_read_concern_empty_document(collection, test: CommandTestCase): + """Test readConcern with empty document uses implicit default.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +_EQUIVALENCE_PARAMS = [ + pytest.param("local", id="equivalence_local"), + pytest.param("available", id="equivalence_available"), + pytest.param("majority", id="equivalence_majority"), +] + + +@pytest.mark.parametrize("level", _EQUIVALENCE_PARAMS) +def test_read_concern_functional_equivalence(collection, level): + """Test all levels return the same results on a single node.""" + collection.insert_many([{"_id": 1, "x": 1}, {"_id": 2, "x": 2}]) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "sort": {"_id": 1}, + "readConcern": {"level": level}, + }, + ) + assertSuccess( + result, + [{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg=f"find with readConcern '{level}' should return same results on single node.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_linearizable.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_linearizable.py new file mode 100644 index 000000000..1bdca9e31 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_linearizable.py @@ -0,0 +1,105 @@ +""" +readConcern linearizable restriction tests. + +Verifies that readConcern level 'linearizable' is accepted by read commands +and properly restricted for aggregate with $out/$merge stages. +All linearizable tests require a replica set topology. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertResult +from documentdb_tests.framework.error_codes import INVALID_OPTIONS_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.replica_set + + +def test_find_accepts_linearizable(collection): + """Test find accepts readConcern level 'linearizable'.""" + collection.insert_many([{"_id": 1, "x": 1}]) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "readConcern": {"level": "linearizable"}}, + ) + assertResult(result, expected=[{"_id": 1, "x": 1}], msg="find should accept linearizable.") + + +def test_count_accepts_linearizable(collection): + """Test count accepts readConcern level 'linearizable'.""" + collection.insert_many([{"_id": 1}]) + result = execute_command( + collection, + {"count": collection.name, "query": {}, "readConcern": {"level": "linearizable"}}, + ) + assertResult( + result, expected={"n": 1, "ok": 1.0}, msg="count should accept linearizable.", raw_res=True + ) + + +def test_distinct_accepts_linearizable(collection): + """Test distinct accepts readConcern level 'linearizable'.""" + collection.insert_many([{"_id": 1, "x": "a"}]) + result = execute_command( + collection, + {"distinct": collection.name, "key": "x", "readConcern": {"level": "linearizable"}}, + ) + assertResult( + result, + expected={"ok": 1.0, "values": ["a"]}, + msg="distinct should accept linearizable.", + raw_res=True, + ) + + +def test_aggregate_accepts_linearizable_simple_pipeline(collection): + """Test aggregate accepts readConcern 'linearizable' with simple pipeline.""" + collection.insert_many([{"_id": 1, "x": 1}]) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"x": 1}}], + "cursor": {}, + "readConcern": {"level": "linearizable"}, + }, + ) + assertResult( + result, + expected=[{"_id": 1, "x": 1}], + msg="aggregate should accept linearizable with simple pipeline.", + ) + + +def test_aggregate_linearizable_rejects_out_stage(collection): + """Test aggregate with linearizable rejects $out stage.""" + collection.insert_many([{"_id": 1}]) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {}}, {"$out": "output_coll"}], + "cursor": {}, + "readConcern": {"level": "linearizable"}, + }, + ) + assertFailureCode( + result, INVALID_OPTIONS_ERROR, msg="aggregate with linearizable should reject $out stage." + ) + + +def test_aggregate_linearizable_rejects_merge_stage(collection): + """Test aggregate with linearizable rejects $merge stage.""" + collection.insert_many([{"_id": 1}]) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {}}, {"$merge": {"into": "output_coll"}}], + "cursor": {}, + "readConcern": {"level": "linearizable"}, + }, + ) + assertFailureCode( + result, INVALID_OPTIONS_ERROR, msg="aggregate with linearizable should reject $merge stage." + ) 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..82123036f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_local.py @@ -0,0 +1,352 @@ +""" +readConcern level 'local' availability and behavior tests. + +Per the MongoDB spec, readConcern level 'local': + - Is the default for reads against primary and secondaries. + - Is available with or without causally consistent sessions and transactions. + - Returns data from the local instance with no majority-write guarantee. + +Tests in this file validate those availability and behavioral properties. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.query_and_write.read_concern.utils import ( + is_cursor_command, +) +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 + +# --------------------------------------------------------------------------- +# Property [Local Is Default]: omitting readConcern is equivalent to level 'local'. +# Both sides (explicit 'local' and no readConcern) are each verified separately +# with one assertion per test, sharing the same expected value. +# --------------------------------------------------------------------------- + +LOCAL_AS_EXPLICIT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_explicit_local", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="find with explicit readConcern 'local' should return all documents.", + ), + CommandTestCase( + "aggregate_explicit_local", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$sort": {"_id": 1}}], + "cursor": {}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="aggregate with explicit readConcern 'local' should return all documents.", + ), + CommandTestCase( + "count_explicit_local", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "count": ctx.collection, + "query": {}, + "readConcern": {"level": "local"}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with explicit readConcern 'local' should return total count.", + ), + CommandTestCase( + "distinct_explicit_local", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + "readConcern": {"level": "local"}, + }, + expected={"ok": 1.0, "values": [1, 2]}, + msg="distinct with explicit readConcern 'local' should return distinct values.", + ), +] + +LOCAL_AS_DEFAULT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_omitted_read_concern", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {}, + "sort": {"_id": 1}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="find without readConcern should return the same data as explicit 'local'.", + ), + CommandTestCase( + "aggregate_omitted_read_concern", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$sort": {"_id": 1}}], + "cursor": {}, + }, + expected=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + msg="aggregate without readConcern should return the same data as explicit 'local'.", + ), + CommandTestCase( + "count_omitted_read_concern", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "count": ctx.collection, + "query": {}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count without readConcern should return the same result as explicit 'local'.", + ), + CommandTestCase( + "distinct_omitted_read_concern", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + }, + expected={"ok": 1.0, "values": [1, 2]}, + msg="distinct without readConcern should return the same result as explicit 'local'.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LOCAL_AS_EXPLICIT_TESTS)) +def test_read_concern_local_explicit(collection, test: CommandTestCase): + """Test that explicit readConcern 'local' returns the expected results.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +@pytest.mark.parametrize("test", pytest_params(LOCAL_AS_DEFAULT_TESTS)) +def test_read_concern_local_is_default(collection, test: CommandTestCase): + """Test that omitting readConcern produces the same result as explicit 'local'.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +# --------------------------------------------------------------------------- +# Property [Local Available Without Session]: level 'local' is available in +# a plain context — no session, no transaction, no replica set required. +# This validates the spec claim that 'local' is available unconditionally. +# --------------------------------------------------------------------------- + +LOCAL_AVAILABILITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_local_available_no_session", + docs=[{"_id": 1, "v": "a"}, {"_id": 2, "v": "b"}], + command=lambda ctx: { + "find": ctx.collection, + "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( + "aggregate_local_available_no_session", + docs=[{"_id": 1, "v": "a"}, {"_id": 2, "v": "b"}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$sort": {"_id": 1}}], + "cursor": {}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 1, "v": "a"}, {"_id": 2, "v": "b"}], + msg="aggregate with readConcern 'local' must be available without a session or transaction.", # noqa: E501 + ), + CommandTestCase( + "count_local_available_no_session", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: { + "count": ctx.collection, + "query": {}, + "readConcern": {"level": "local"}, + }, + expected={"n": 3, "ok": 1.0}, + msg="count with readConcern 'local' must be available without a session or transaction.", + ), + CommandTestCase( + "distinct_local_available_no_session", + docs=[{"_id": 1, "cat": "x"}, {"_id": 2, "cat": "y"}, {"_id": 3, "cat": "x"}], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "cat", + "readConcern": {"level": "local"}, + }, + expected={"ok": 1.0, "values": ["x", "y"]}, + msg="distinct with readConcern 'local' must be available without a session or transaction.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LOCAL_AVAILABILITY_TESTS)) +def test_read_concern_local_available_without_session(collection, test: CommandTestCase): + """Test readConcern 'local' is available without a session or transaction.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +# --------------------------------------------------------------------------- +# Property [Local Reads Local State]: level 'local' returns the current state +# of the local instance immediately after a write, with no propagation delay. +# This confirms that freshly-inserted data is visible under 'local'. +# --------------------------------------------------------------------------- + +LOCAL_READS_FRESH_DATA_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_local_sees_fresh_insert", + docs=[{"_id": 1, "score": 100}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"score": 100}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 1, "score": 100}], + msg="find with readConcern 'local' must see data immediately after insert.", + ), + CommandTestCase( + "aggregate_local_sees_fresh_insert", + docs=[{"_id": 1, "score": 100}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"score": 100}}], + "cursor": {}, + "readConcern": {"level": "local"}, + }, + expected=[{"_id": 1, "score": 100}], + msg="aggregate with readConcern 'local' must see data immediately after insert.", + ), + CommandTestCase( + "count_local_sees_fresh_insert", + docs=[{"_id": 1, "score": 100}, {"_id": 2, "score": 200}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"score": {"$gte": 100}}, + "readConcern": {"level": "local"}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with readConcern 'local' must count freshly inserted documents.", + ), + CommandTestCase( + "distinct_local_sees_fresh_insert", + docs=[{"_id": 1, "tag": "new"}, {"_id": 2, "tag": "new"}, {"_id": 3, "tag": "old"}], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "tag", + "readConcern": {"level": "local"}, + }, + expected={"ok": 1.0, "values": ["new", "old"]}, + msg="distinct with readConcern 'local' must reflect freshly inserted values.", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LOCAL_READS_FRESH_DATA_TESTS)) +def test_read_concern_local_reads_fresh_data(collection, test: CommandTestCase): + """Test readConcern 'local' returns data immediately after a local write.""" + collection = test.prepare(collection.database, collection) + 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=not is_cursor_command(test), + ) + + +# --------------------------------------------------------------------------- +# Property [Local Reads Updated State]: level 'local' reflects updates to +# existing documents without any additional propagation requirement. +# --------------------------------------------------------------------------- + + +def test_find_local_sees_updated_document(collection): + """Test find with readConcern 'local' reflects an in-place update immediately.""" + collection.insert_many([{"_id": 1, "status": "pending"}]) + + # Update the document using a write command. + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"status": "done"}}}], + }, + ) + + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + ) + assertResult( + result, + expected=[{"_id": 1, "status": "done"}], + msg="find with readConcern 'local' must reflect an update applied to the local instance.", + ) + + +def test_count_local_reflects_delete(collection): + """Test count with readConcern 'local' reflects a deletion immediately.""" + collection.insert_many([{"_id": 1}, {"_id": 2}, {"_id": 3}]) + + execute_command( + collection, + { + "delete": collection.name, + "deletes": [{"q": {"_id": 1}, "limit": 1}], + }, + ) + + result = execute_command( + collection, + { + "count": collection.name, + "query": {}, + "readConcern": {"level": "local"}, + }, + ) + assertResult( + result, + expected={"n": 2, "ok": 1.0}, + msg="count with readConcern 'local' must reflect a deletion applied to the local instance.", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_replica_set.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_replica_set.py new file mode 100644 index 000000000..aa2fcf910 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_replica_set.py @@ -0,0 +1,59 @@ +""" +readConcern afterClusterTime validation tests (replica set). + +Verifies that afterClusterTime in readConcern rejects invalid types. +These tests require a replica set topology. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.replica_set + + +def test_find_afterClusterTime_rejects_string(collection): + """Test find rejects non-Timestamp afterClusterTime (string).""" + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": {"level": "local", "afterClusterTime": "invalid"}, + }, + ) + assertFailureCode( + result, TYPE_MISMATCH_ERROR, msg="find should reject non-Timestamp afterClusterTime string." + ) + + +def test_find_afterClusterTime_rejects_integer(collection): + """Test find rejects non-Timestamp afterClusterTime (integer).""" + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": {"level": "local", "afterClusterTime": 12345}, + }, + ) + assertFailureCode( + result, + TYPE_MISMATCH_ERROR, + msg="find should reject non-Timestamp afterClusterTime integer.", + ) + + +def test_find_afterClusterTime_rejects_null(collection): + """Test find rejects null afterClusterTime.""" + result = execute_command( + collection, + { + "find": collection.name, + "filter": {}, + "readConcern": {"level": "local", "afterClusterTime": None}, + }, + ) + assertFailureCode(result, TYPE_MISMATCH_ERROR, msg="find should reject null afterClusterTime.") diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_snapshot.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_snapshot.py new file mode 100644 index 000000000..e83024587 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_snapshot.py @@ -0,0 +1,62 @@ +""" +readConcern snapshot outside transaction tests. + +Verifies that readConcern level 'snapshot' is rejected when used outside +a multi-document transaction. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + NOT_A_REPLICA_SET_ERROR, +) +from documentdb_tests.framework.executor import execute_command + +_SNAPSHOT_REPL_SET_PARAMS = [ + pytest.param( + lambda coll: {"find": coll, "filter": {}, "readConcern": {"level": "snapshot"}}, + id="find_snapshot_outside_transaction", + ), + pytest.param( + lambda coll: { + "aggregate": coll, + "pipeline": [], + "cursor": {}, + "readConcern": {"level": "snapshot"}, + }, + id="aggregate_snapshot_outside_transaction", + ), + pytest.param( + lambda coll: {"distinct": coll, "key": "_id", "readConcern": {"level": "snapshot"}}, + id="distinct_snapshot_outside_transaction", + ), +] + + +@pytest.mark.parametrize("build_command", _SNAPSHOT_REPL_SET_PARAMS) +def test_read_concern_snapshot_outside_transaction(collection, build_command): + """Test readConcern 'snapshot' outside a transaction is rejected.""" + collection.insert_many([{"_id": 1}]) + command = build_command(collection.name) + result = execute_command(collection, command) + assertFailureCode( + result, + NOT_A_REPLICA_SET_ERROR, + msg="readConcern 'snapshot' should be rejected outside a transaction.", + ) + + +def test_count_snapshot_outside_transaction(collection): + """Test count with readConcern 'snapshot' outside a transaction is rejected.""" + collection.insert_many([{"_id": 1}]) + result = execute_command( + collection, + {"count": collection.name, "query": {}, "readConcern": {"level": "snapshot"}}, + ) + assertFailureCode( + result, + INVALID_OPTIONS_ERROR, + msg="count with readConcern 'snapshot' should be rejected outside a transaction.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_views.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_views.py new file mode 100644 index 000000000..66cccc410 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_views.py @@ -0,0 +1,60 @@ +""" +readConcern with views tests. + +Verifies that readConcern is accepted when querying views. +""" + +import uuid + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command + + +@pytest.fixture +def view_collection(database_client, collection): + """Create a view on top of the test collection.""" + collection.insert_many([{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 5}]) + view_name = f"rc_view_{uuid.uuid4().hex[:8]}" + database_client.command( + "create", view_name, viewOn=collection.name, pipeline=[{"$match": {"x": {"$gte": 10}}}] + ) + yield database_client[view_name] + database_client.drop_collection(view_name) + + +def test_find_read_concern_on_view(view_collection): + """Test find with readConcern on a view returns view results.""" + result = execute_command( + view_collection, + { + "find": view_collection.name, + "filter": {}, + "sort": {"_id": 1}, + "readConcern": {"level": "local"}, + }, + ) + assertSuccess( + result, + [{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + msg="find with readConcern on view should return filtered view results.", + ) + + +def test_aggregate_read_concern_on_view(view_collection): + """Test aggregate with readConcern on a view returns view results.""" + result = execute_command( + view_collection, + { + "aggregate": view_collection.name, + "pipeline": [{"$sort": {"_id": 1}}], + "cursor": {}, + "readConcern": {"level": "majority"}, + }, + ) + assertSuccess( + result, + [{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + msg="aggregate with readConcern on view should return view results.", + ) 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..9ad21bf85 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/test_read_concern_write_commands.py @@ -0,0 +1,80 @@ +""" +readConcern on write commands outside transactions. + +Verifies the behavior when readConcern is specified on write commands +(insert, update, delete, findAndModify) outside of a multi-document transaction. +""" + +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 + +# Property [Write Command Acceptance]: write commands accept readConcern outside transactions. +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.", + ), +] + + +@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)) + assertResult(result, expected=test.build_expected(ctx), msg=test.msg, raw_res=True) + + +def test_find_and_modify_with_read_concern_outside_transaction(collection): + """Test findAndModify with readConcern outside transaction.""" + collection.insert_many([{"_id": 1, "x": 1}]) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"x": 2}}, + "readConcern": {"level": "local"}, + }, + ) + assertSuccessPartial( + result, + {"value": {"_id": 1, "x": 1}, "ok": 1.0}, + msg="findAndModify should accept readConcern outside transaction.", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/utils/__init__.py b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/utils/__init__.py new file mode 100644 index 000000000..e5189b7e0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/read_concern/utils/__init__.py @@ -0,0 +1,8 @@ +"""Shared helpers for readConcern tests.""" + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase + + +def is_cursor_command(test: CommandTestCase) -> bool: + """Return True if the test targets a cursor-returning command (find/aggregate).""" + return test.id.startswith("find_") or test.id.startswith("aggregate_")