diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/__init__.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_batch_retrieval.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_batch_retrieval.py new file mode 100644 index 000000000..bf72b5646 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_batch_retrieval.py @@ -0,0 +1,131 @@ +"""Tests for getMore batch retrieval behavior.""" + +from __future__ import annotations + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + open_cursor, + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Eq, Gte, Len +from documentdb_tests.framework.target_collection import CappedCollection +from documentdb_tests.framework.test_constants import INT64_ZERO + + +# Property [Batch Retrieval]: getMore returns the next batch of documents from a cursor. +def test_getMore_batch_from_find(collection): + """Test getMore returns subsequent batches from a find cursor.""" + collection.insert_many([{"_id": i, "v": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=3) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 4}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "cursor": {"nextBatch": Eq([{"_id": i, "v": i} for i in range(3, 7)])}, + }, + msg="getMore should return next 4 documents from find cursor", + raw_res=True, + ) + + +# Property [batchSize Zero Tailable]: getMore with batchSize=0 on a tailable +# cursor returns available documents and keeps the cursor open. +def test_getMore_batch_size_zero_tailable_keeps_open(collection): + """Test getMore with batchSize=0 on tailable cursor keeps it open.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "batchSize": 2}) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": capped.name, "batchSize": 0}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(3), "id": Gte(1)}}, + msg="getMore batchSize=0 on tailable cursor should return docs and keep open", + raw_res=True, + ) + + +# Property [batchSize Independence]: each getMore call applies its own batchSize +# independent of the find batchSize and prior calls. +def test_getMore_batch_size_independent(collection): + """Test each getMore call sets its own batchSize independently.""" + docs = [{"_id": i, "v": i} for i in range(10)] + collection.insert_many(docs) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + result1 = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 3}, + ) + cursor_id = result1["cursor"]["id"] + result2 = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 5}, + ) + assertResult( + result2, + expected={ + "ok": Eq(1.0), + "cursor": {"nextBatch": Eq([{"_id": i, "v": i} for i in range(5, 10)])}, + }, + msg="Second getMore should return 5 documents independently of find batchSize", + raw_res=True, + ) + + +# Property [Limit Cap]: the originating find's limit caps the total number of +# documents returned across getMore calls. +def test_getMore_find_limit_caps_total(collection): + """Test the originating find's limit caps total documents across getMore calls.""" + docs = [{"_id": i, "v": i} for i in range(10)] + collection.insert_many(docs) + find_result = execute_command(collection, {"find": collection.name, "batchSize": 2, "limit": 5}) + cursor_id = find_result["cursor"]["id"] + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 10}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(3), "id": Eq(INT64_ZERO)}}, + msg="getMore should return only 3 more documents to respect limit=5", + raw_res=True, + ) + + +# Property [Cursor Independence]: separate cursors on the same collection +# maintain independent iteration positions. +def test_getMore_independent_cursors(collection): + """Test two cursors on the same collection maintain independent positions.""" + docs = [{"_id": i, "v": i} for i in range(10)] + collection.insert_many(docs) + (cursor_a,) = open_find_cursors(collection, 1, batch_size=3) + (cursor_b,) = open_find_cursors(collection, 1, batch_size=5) + result_a = execute_command( + collection, + {"getMore": cursor_a, "collection": collection.name, "batchSize": 2}, + ) + result_b = execute_command( + collection, + {"getMore": cursor_b, "collection": collection.name, "batchSize": 2}, + ) + # Cursor A started at position 3, cursor B started at position 5. + combined = { + "a": result_a["cursor"]["nextBatch"], + "b": result_b["cursor"]["nextBatch"], + } + assertResult( + combined, + expected={ + "a": Eq([{"_id": 3, "v": 3}, {"_id": 4, "v": 4}]), + "b": Eq([{"_id": 5, "v": 5}, {"_id": 6, "v": 6}]), + }, + msg="Two cursors should maintain independent positions", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_batch_size.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_batch_size.py new file mode 100644 index 000000000..04febefeb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_batch_size.py @@ -0,0 +1,506 @@ +"""Tests for getMore batchSize numeric coercion and limits.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + CursorCommandContext, + CursorCommandTestCase, + open_find_cursors, +) +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, Len +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_MAX, + DECIMAL128_MAX_COEFFICIENT, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_HALF, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + DOUBLE_MAX, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_NAN, + INT32_MAX, + INT64_MAX, + INT64_ZERO, +) + +# Number of documents inserted before each coercion test. The originating find +# uses find_batch_size, so DOC_COUNT minus find_batch_size documents remain for +# the getMore under test. +DOC_COUNT = 10 + +# Property [Integer Pass-Through]: integer batchSize values are used as the +# batch size directly without coercion. +GETMORE_BATCH_SIZE_INTEGER_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "integer_int32", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": 4, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(4)}}, + msg="getMore should use int32 batchSize as-is", + ), + CursorCommandTestCase( + "integer_int64", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": Int64(4), + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(4)}}, + msg="getMore should use int64 batchSize as-is", + ), +] + +# Property [batchSize Numeric Coercion]: fractional batchSize values that round +# to a positive integer use that integer as the batch size, with rounding that +# depends on the numeric type (doubles truncate toward zero, Decimal128 uses +# banker's rounding). +GETMORE_BATCH_SIZE_COERCION_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "coerce_double_1_5", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": 1.5, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(1)}}, + msg="getMore should truncate double 1.5 to 1", + ), + CursorCommandTestCase( + "coerce_decimal_1_5", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_ONE_AND_HALF, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(2)}}, + msg="getMore should round Decimal128 1.5 to 2 (banker's rounding, even)", + ), + CursorCommandTestCase( + "coerce_decimal_2_5", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_TWO_AND_HALF, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(2)}}, + msg="getMore should round Decimal128 2.5 to 2 (banker's rounding, even)", + ), + CursorCommandTestCase( + "coerce_decimal_trailing_zeros_1_0", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_TRAILING_ZERO, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(1)}}, + msg="getMore should round Decimal128 1.0 to 1 (trailing zeros ignored)", + ), + CursorCommandTestCase( + "coerce_decimal_just_above_half", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_JUST_ABOVE_HALF, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(1)}}, + msg="getMore should round Decimal128 just above 0.5 to 1", + ), +] + +# Property [batchSize Effective Zero]: an omitted or null batchSize, literal 0, +# and any value that coerces to 0 (NaN of either sign, negative zero, subnormal, +# and fractional values that round to 0) return all remaining documents and +# close a non-tailable cursor (cursor.id 0). +GETMORE_BATCH_SIZE_EFFECTIVE_ZERO_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "zero_omitted", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: {"getMore": ctx.cursors[0], "collection": ctx.collection}, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore without batchSize should return all remaining and close cursor", + ), + CursorCommandTestCase( + "zero_null", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": None, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore batchSize=null should return all remaining and close cursor", + ), + CursorCommandTestCase( + "zero_literal", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": 0, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore batchSize=0 should return all remaining and close non-tailable cursor", + ), + CursorCommandTestCase( + "zero_double_0_5", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": 0.5, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should truncate double 0.5 to 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_double_neg_0_99999", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": -0.99999, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should truncate double -0.99999 to 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_decimal_0_5", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_HALF, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should round Decimal128 0.5 to 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_decimal_neg_0_5", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_NEGATIVE_HALF, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should round Decimal128 -0.5 to 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_decimal_just_below_half", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_JUST_BELOW_HALF, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should round Decimal128 just below 0.5 to 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_double_nan", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": FLOAT_NAN, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should treat double NaN as batchSize 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_decimal_nan", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_NAN, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should treat Decimal128 NaN as batchSize 0 (returns all and closes cursor)", + ), + # Negative NaN ignores the sign bit and behaves like NaN (coerces to 0). + CursorCommandTestCase( + "zero_double_neg_nan", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": FLOAT_NEGATIVE_NAN, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should treat double -NaN as batchSize 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_decimal_neg_nan", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_NEGATIVE_NAN, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should treat Decimal128 -NaN as batchSize 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_double_neg_zero", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DOUBLE_NEGATIVE_ZERO, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should accept double -0.0 as batchSize 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_decimal_neg_zero", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_NEGATIVE_ZERO, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should accept Decimal128 -0 as batchSize 0 (returns all and closes cursor)", + ), + CursorCommandTestCase( + "zero_double_subnormal", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DOUBLE_MIN_SUBNORMAL, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2), "id": Eq(INT64_ZERO)}}, + msg="getMore should truncate subnormal double to 0 (returns all and closes cursor)", + ), +] + +# Property [batchSize Value Boundaries]: batchSize=1 is the minimum effective +# size, and large values up to and beyond each numeric type's maximum (including +# infinity) are accepted and return all remaining documents with no upper bound. +GETMORE_BATCH_SIZE_BOUNDARY_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "boundary_one", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": 1, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(1)}}, + msg="getMore batchSize=1 should return exactly 1 document", + ), + CursorCommandTestCase( + "boundary_int32_max", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": INT32_MAX, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2)}}, + msg="getMore should accept int32 max as batchSize and return all remaining", + ), + CursorCommandTestCase( + "boundary_int64_max", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": INT64_MAX, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2)}}, + msg="getMore should accept int64 max as batchSize and return all remaining", + ), + CursorCommandTestCase( + "boundary_double_2_pow_53", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DOUBLE_MAX_SAFE_INTEGER, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2)}}, + msg="getMore should accept double 2^53 and return all remaining", + ), + CursorCommandTestCase( + "boundary_double_max", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DOUBLE_MAX, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2)}}, + msg="getMore should accept double max as batchSize and return all remaining", + ), + CursorCommandTestCase( + "boundary_decimal_beyond_int64_max", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_MAX_COEFFICIENT, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2)}}, + msg="getMore should accept Decimal128 beyond int64 max and return all remaining", + ), + CursorCommandTestCase( + "boundary_decimal_max", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_MAX, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2)}}, + msg="getMore should accept Decimal128 max as batchSize and return all remaining", + ), + CursorCommandTestCase( + "boundary_double_infinity", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": FLOAT_INFINITY, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2)}}, + msg="getMore should treat double Infinity as returning all remaining", + ), + CursorCommandTestCase( + "boundary_decimal_infinity", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": DECIMAL128_INFINITY, + }, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(DOC_COUNT - 2)}}, + msg="getMore should treat Decimal128 Infinity as returning all remaining", + ), +] + +GETMORE_BATCH_SIZE_TESTS = ( + GETMORE_BATCH_SIZE_INTEGER_TESTS + + GETMORE_BATCH_SIZE_COERCION_TESTS + + GETMORE_BATCH_SIZE_EFFECTIVE_ZERO_TESTS + + GETMORE_BATCH_SIZE_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(GETMORE_BATCH_SIZE_TESTS)) +def test_getMore_batch_size_coercion(collection, test_case: CursorCommandTestCase): + """Test getMore batchSize numeric coercion.""" + collection.insert_many([{"_id": i, "v": i} for i in range(DOC_COUNT)]) + cursors = open_find_cursors( + collection, test_case.cursor_count, batch_size=test_case.find_batch_size + ) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [16 MiB Batch Limit]: the 16 MiB batch limit caps the number of +# documents returned when documents are large. +def test_getMore_16mib_batch_limit(collection): + """Test getMore 16 MiB batch limit caps large documents.""" + # Each document is ~1.1 MB, so the 16 MiB batch limit allows exactly 15. + big_val = "x" * 1_100_000 + docs = [{"_id": i, "data": big_val} for i in range(20)] + collection.insert_many(docs) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(15)}}, + msg="getMore should cap batch at 16 MiB worth of documents", + raw_res=True, + ) + + +# Property [batchSize Below Size Limit]: batchSize caps the batch when it is +# smaller than what the 16 MiB size limit would allow. +def test_getMore_16mib_batch_size_wins(collection): + """Test getMore batchSize wins when smaller than 16 MiB would allow.""" + big_val = "x" * 1_100_000 + docs = [{"_id": i, "data": big_val} for i in range(20)] + collection.insert_many(docs) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 3}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(3)}}, + msg="getMore batchSize=3 should return exactly 3 documents despite 16 MiB allowing more", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_comment.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_comment.py new file mode 100644 index 000000000..d6c7b9726 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_comment.py @@ -0,0 +1,111 @@ +"""Tests for getMore comment field behavior.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + CursorCommandContext, + CursorCommandTestCase, + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [Comment Type Acceptance]: all BSON types are accepted for comment +# without error and no type validation is performed. +GETMORE_COMMENT_TYPE_ACCEPTANCE_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + f"comment_{tid}", + cursor_count=1, + find_batch_size=2, + command=lambda ctx, v=val: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "comment": v, + }, + expected={"ok": Eq(1.0)}, + msg=f"getMore should accept {tid} as comment", + ) + for tid, val in [ + ("string", "hello"), + ("int32", 42), + ("int64", Int64(123)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("null", None), + ("array", [1, "two", 3.0]), + ("object", {"key": "value"}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2023, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("^test", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Comment Does Not Alter Errors]: comment does not prevent or alter +# errors from other invalid parameters. +GETMORE_COMMENT_ERROR_PASSTHROUGH_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "comment_with_invalid_batch_size", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "batchSize": -1, + "comment": "test_comment", + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should still produce batchSize error when comment is present", + ), +] + +# Property [Comment Omitted Success]: getMore succeeds when the comment field +# is omitted. +GETMORE_COMMENT_OMITTED_SUCCESS_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "comment_omitted", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: {"getMore": ctx.cursors[0], "collection": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="getMore should succeed when comment is omitted", + ), +] + +GETMORE_COMMENT_TESTS = ( + GETMORE_COMMENT_TYPE_ACCEPTANCE_TESTS + + GETMORE_COMMENT_ERROR_PASSTHROUGH_TESTS + + GETMORE_COMMENT_OMITTED_SUCCESS_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(GETMORE_COMMENT_TESTS)) +def test_getMore_comment(collection, test_case: CursorCommandTestCase): + """Test getMore comment field acceptance and error passthrough.""" + collection.insert_many([{"_id": i} for i in range(5)]) + cursors = open_find_cursors( + collection, test_case.cursor_count, batch_size=test_case.find_batch_size + ) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_cursor_sources.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_cursor_sources.py new file mode 100644 index 000000000..8f471526f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_cursor_sources.py @@ -0,0 +1,147 @@ +"""Tests for getMore against cursors from different source commands. + +find cursors are exercised exhaustively across the other getMore test files. +This file holds one representative per other cursor source (aggregate, view, +time-series, listCollections, listIndexes), confirming each source's cursor +reaches the same getMore handling. +""" + +from __future__ import annotations + +import datetime + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Eq, Len + + +# Property [Aggregate Cursor Source]: getMore retrieves a subsequent batch from +# an aggregate cursor. +def test_getMore_aggregate_cursor(collection): + """Test getMore returns a subsequent batch from an aggregate cursor.""" + collection.insert_many([{"_id": i} for i in range(10)]) + agg_result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": [], "cursor": {"batchSize": 2}}, + ) + cursor_id = agg_result["cursor"]["id"] + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 3}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(3)}}, + msg="getMore should return next batch from an aggregate cursor", + raw_res=True, + ) + + +# Property [View Cursor Source]: getMore retrieves a subsequent batch from a +# cursor over a view, addressed by the view name. +def test_getMore_view_cursor(collection): + """Test getMore returns a subsequent batch from a view cursor.""" + collection.insert_many([{"_id": i} for i in range(5)]) + view_name = f"{collection.name}_view" + execute_command(collection, {"create": view_name, "viewOn": collection.name, "pipeline": []}) + find_result = execute_command(collection, {"find": view_name, "batchSize": 2}) + cursor_id = find_result["cursor"]["id"] + result = execute_command( + collection, + {"getMore": cursor_id, "collection": view_name}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(3)}}, + msg="getMore should return next batch from a view cursor", + raw_res=True, + ) + + +# Property [Time-Series Cursor Source]: getMore retrieves a subsequent batch +# from a cursor over a time-series collection. +def test_getMore_timeseries_cursor(collection): + """Test getMore returns a subsequent batch from a time-series cursor.""" + ts_name = f"{collection.name}_ts" + execute_command(collection, {"create": ts_name, "timeseries": {"timeField": "ts"}}) + execute_command( + collection, + { + "insert": ts_name, + "documents": [ + {"ts": datetime.datetime(2024, 1, 1) + datetime.timedelta(minutes=i)} + for i in range(20) + ], + }, + ) + find_result = execute_command(collection, {"find": ts_name, "batchSize": 2}) + cursor_id = find_result["cursor"]["id"] + result = execute_command( + collection, + {"getMore": cursor_id, "collection": ts_name, "batchSize": 3}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(3)}}, + msg="getMore should return next batch from a time-series cursor", + raw_res=True, + ) + + +# Property [listCollections Cursor Source]: getMore retrieves a subsequent +# batch from a listCollections cursor, addressed by $cmd.listCollections. +def test_getMore_list_collections_cursor(collection): + """Test getMore succeeds for a listCollections cursor.""" + for i in range(5): + execute_command(collection, {"create": f"{collection.name}_lc_{i}"}) + list_result = execute_command( + collection, + {"listCollections": 1, "cursor": {"batchSize": 1}}, + ) + cursor_id = list_result["cursor"]["id"] + if cursor_id == 0: + pytest.skip("listCollections returned all results in first batch") + result = execute_command( + collection, + {"getMore": cursor_id, "collection": "$cmd.listCollections"}, + ) + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="getMore should succeed for a listCollections cursor", + raw_res=True, + ) + + +# Property [listIndexes Cursor Source]: getMore retrieves a subsequent batch +# from a listIndexes cursor, addressed by the collection name. +def test_getMore_list_indexes_cursor(collection): + """Test getMore succeeds for a listIndexes cursor.""" + collection.insert_many([{"_id": i} for i in range(3)]) + for i in range(10): + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {f"f{i}": 1}, "name": f"idx_{i}"}], + }, + ) + list_result = execute_command( + collection, + {"listIndexes": collection.name, "cursor": {"batchSize": 1}}, + ) + cursor_id = list_result["cursor"]["id"] + if cursor_id == 0: + pytest.skip("listIndexes returned all results in first batch") + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="getMore should succeed for a listIndexes cursor", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_field_type_errors.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_field_type_errors.py new file mode 100644 index 000000000..82273c976 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_field_type_errors.py @@ -0,0 +1,164 @@ +"""Tests for getMore field type validation.""" + +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.cursors.commands.utils.cursor_test_case import ( + CursorCommandContext, + CursorCommandTestCase, + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Cursor ID Type Strictness]: only int64 (long) is accepted for the +# getMore field; all other BSON types produce TYPE_MISMATCH_ERROR with no +# numeric coercion. +GETMORE_CURSOR_ID_TYPE_STRICTNESS_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + f"cursor_id_type_{tid}", + cursor_count=0, + command=lambda ctx, v=val: {"getMore": v, "collection": ctx.collection}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getMore should reject {tid} cursor ID", + ) + for tid, val in [ + ("int32", 42), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool", True), + ("string", "123"), + ("array", [Int64(1)]), + ("object", {"id": Int64(1)}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2023, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Collection Type Error]: all non-string, non-null types for the +# collection field produce TYPE_MISMATCH_ERROR. +GETMORE_COLLECTION_TYPE_ERROR_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + f"collection_type_{tid}", + cursor_count=0, + command=lambda ctx, v=val: {"getMore": Int64(1), "collection": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getMore should reject {tid} collection field", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", ["test"]), + ("object", {"name": "test"}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2023, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [batchSize Type Error]: non-numeric types for batchSize produce +# TYPE_MISMATCH_ERROR; only int32, int64, double, Decimal128, and null are +# accepted. +GETMORE_BATCH_SIZE_TYPE_ERROR_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + f"batch_size_type_{tid}", + cursor_count=0, + command=lambda ctx, v=val: { + "getMore": Int64(1), + "collection": ctx.collection, + "batchSize": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getMore should reject {tid} batchSize", + ) + for tid, val in [ + ("bool", True), + ("string", "1"), + ("array", [1]), + ("object", {"n": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2023, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [maxTimeMS Type Error]: non-numeric types for maxTimeMS produce +# TYPE_MISMATCH_ERROR; only int32, int64, double, Decimal128, and null are +# accepted. +GETMORE_MAX_TIME_MS_TYPE_ERROR_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + f"max_time_ms_type_{tid}", + cursor_count=0, + command=lambda ctx, v=val: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getMore should reject {tid} maxTimeMS", + ) + for tid, val in [ + ("bool", True), + ("string", "100"), + ("array", [100]), + ("object", {"ms": 100}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2023, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +GETMORE_FIELD_TYPE_ERROR_TESTS = ( + GETMORE_CURSOR_ID_TYPE_STRICTNESS_TESTS + + GETMORE_COLLECTION_TYPE_ERROR_TESTS + + GETMORE_BATCH_SIZE_TYPE_ERROR_TESTS + + GETMORE_MAX_TIME_MS_TYPE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(GETMORE_FIELD_TYPE_ERROR_TESTS)) +def test_getMore_field_type_errors(collection, test_case: CursorCommandTestCase): + """Test getMore field type validation.""" + collection.insert_many([{"_id": i, "v": i} for i in range(5)]) + cursors = open_find_cursors( + collection, test_case.cursor_count, batch_size=test_case.find_batch_size + ) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_field_value_errors.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_field_value_errors.py new file mode 100644 index 000000000..82452a763 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_field_value_errors.py @@ -0,0 +1,404 @@ +"""Tests for getMore field value validation.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + CursorCommandContext, + CursorCommandTestCase, + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + CURSOR_NOT_FOUND_ERROR, + FAILED_TO_PARSE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_MAX, + INT64_MAX, + INT64_MIN, + INT64_ZERO, +) + +# Property [Cursor ID Not Found]: any int64 value that does not correspond to +# an existing cursor produces CURSOR_NOT_FOUND_ERROR, including 0, -1, and +# boundary values. +GETMORE_CURSOR_ID_NOT_FOUND_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "cursor_id_not_found_zero", + cursor_count=0, + command=lambda ctx: {"getMore": INT64_ZERO, "collection": ctx.collection}, + error_code=CURSOR_NOT_FOUND_ERROR, + msg="getMore should produce CursorNotFound for cursor ID 0", + ), + CursorCommandTestCase( + "cursor_id_not_found_neg_one", + cursor_count=0, + command=lambda ctx: {"getMore": Int64(-1), "collection": ctx.collection}, + error_code=CURSOR_NOT_FOUND_ERROR, + msg="getMore should produce CursorNotFound for cursor ID -1", + ), + CursorCommandTestCase( + "cursor_id_not_found_int64_max", + cursor_count=0, + command=lambda ctx: {"getMore": INT64_MAX, "collection": ctx.collection}, + error_code=CURSOR_NOT_FOUND_ERROR, + msg="getMore should produce CursorNotFound for cursor ID at int64 max", + ), + CursorCommandTestCase( + "cursor_id_not_found_int64_min", + cursor_count=0, + command=lambda ctx: {"getMore": INT64_MIN, "collection": ctx.collection}, + error_code=CURSOR_NOT_FOUND_ERROR, + msg="getMore should produce CursorNotFound for cursor ID at int64 min", + ), +] + +# Property [batchSize Negative Value Error]: negative numeric values that +# coerce to a negative integer produce BAD_VALUE_ERROR. +GETMORE_BATCH_SIZE_NEGATIVE_VALUE_ERROR_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "batch_size_neg_int32", + cursor_count=0, + command=lambda ctx: {"getMore": Int64(1), "collection": ctx.collection, "batchSize": -1}, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject negative int32 batchSize", + ), + CursorCommandTestCase( + "batch_size_neg_int64", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "batchSize": Int64(-5), + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject negative int64 batchSize", + ), + CursorCommandTestCase( + "batch_size_neg_double", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "batchSize": -1.5, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject negative double -1.5 which truncates to -1", + ), + # Decimal128 -0.50001 rounds to -1 via banker's rounding. + CursorCommandTestCase( + "batch_size_neg_decimal_below_half", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "batchSize": Decimal128("-0.50001"), + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject Decimal128 -0.50001 which rounds to -1", + ), + CursorCommandTestCase( + "batch_size_neg_inf_double", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "batchSize": FLOAT_NEGATIVE_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject negative infinity double batchSize", + ), + CursorCommandTestCase( + "batch_size_neg_inf_decimal", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "batchSize": DECIMAL128_NEGATIVE_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject negative infinity Decimal128 batchSize", + ), +] + +# Property [maxTimeMS Non-Integer Error]: numeric values that do not represent +# a whole integer produce FAILED_TO_PARSE_ERROR because maxTimeMS requires an +# integer. +GETMORE_MAX_TIME_MS_NON_INTEGER_ERROR_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "max_time_ms_non_int_double_2_5", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": 2.5, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject fractional double 2.5 as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_double_0_1", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": 0.1, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject fractional double 0.1 as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_double_0_9", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": 0.9, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject fractional double 0.9 as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_decimal_fractional", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": DECIMAL128_ONE_AND_HALF, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject fractional Decimal128 as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_double_nan", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": FLOAT_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject double NaN as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_double_neg_nan", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": FLOAT_NEGATIVE_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject double -NaN as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_double_infinity", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": FLOAT_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject double Infinity as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_double_neg_infinity", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": FLOAT_NEGATIVE_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject double -Infinity as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_decimal_nan", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": DECIMAL128_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject Decimal128 NaN as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_decimal_neg_nan", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": DECIMAL128_NEGATIVE_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject Decimal128 -NaN as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_decimal_infinity", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": DECIMAL128_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject Decimal128 Infinity as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_decimal_neg_infinity", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": DECIMAL128_NEGATIVE_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject Decimal128 -Infinity as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_non_int_decimal_overflow_int64", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": Decimal128("1E+20"), + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="getMore should reject Decimal128 that overflows int64 as maxTimeMS", + ), +] + +# Property [maxTimeMS Range Error]: integer values above 2147483647 or below 0 +# produce BAD_VALUE_ERROR. +GETMORE_MAX_TIME_MS_RANGE_ERROR_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "max_time_ms_range_above_int32_max", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": INT32_MAX + 1, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject maxTimeMS above max int32", + ), + CursorCommandTestCase( + "max_time_ms_range_large_int64", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": INT64_MAX, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject maxTimeMS at int64 max", + ), + CursorCommandTestCase( + "max_time_ms_range_negative_one", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": -1, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject negative maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_range_negative_int64", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": INT64_MIN, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject maxTimeMS at int64 min", + ), + CursorCommandTestCase( + "max_time_ms_range_double_above_int32_max", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": float(INT32_MAX + 1), + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject whole-integer double above max int32 as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_range_double_negative", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": -1.0, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject whole-integer negative double as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_range_decimal_above_int32_max", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": Decimal128(str(INT32_MAX + 1)), + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject whole-integer Decimal128 above max int32 as maxTimeMS", + ), + CursorCommandTestCase( + "max_time_ms_range_decimal_negative", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "maxTimeMS": Decimal128("-1"), + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject whole-integer negative Decimal128 as maxTimeMS", + ), +] + +GETMORE_FIELD_VALUE_ERROR_TESTS = ( + GETMORE_CURSOR_ID_NOT_FOUND_TESTS + + GETMORE_BATCH_SIZE_NEGATIVE_VALUE_ERROR_TESTS + + GETMORE_MAX_TIME_MS_NON_INTEGER_ERROR_TESTS + + GETMORE_MAX_TIME_MS_RANGE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(GETMORE_FIELD_VALUE_ERROR_TESTS)) +def test_getMore_field_value_errors(collection, test_case: CursorCommandTestCase): + """Test getMore field value validation.""" + collection.insert_many([{"_id": i, "v": i} for i in range(5)]) + cursors = open_find_cursors( + collection, test_case.cursor_count, batch_size=test_case.find_batch_size + ) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_lifecycle.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_lifecycle.py new file mode 100644 index 000000000..9bb31edbb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_lifecycle.py @@ -0,0 +1,242 @@ +"""Tests for getMore cursor lifecycle behavior.""" + +from __future__ import annotations + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + open_cursor, + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CURSOR_NOT_FOUND_ERROR, + QUERY_PLAN_KILLED_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Eq, Gte +from documentdb_tests.framework.target_collection import CappedCollection +from documentdb_tests.framework.test_constants import INT64_ZERO + + +# Property [Cursor Lifecycle - Exhausted]: getMore on an exhausted cursor +# produces CURSOR_NOT_FOUND_ERROR. +def test_getMore_exhausted_cursor_error(collection): + """Test getMore on an exhausted cursor produces CursorNotFound.""" + collection.insert_many([{"_id": i} for i in range(4)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 10}, + ) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertResult( + result, + error_code=CURSOR_NOT_FOUND_ERROR, + msg="getMore should produce CursorNotFound on an exhausted cursor", + raw_res=True, + ) + + +# Property [Cursor Lifecycle - Killed]: getMore on a killed cursor produces +# CURSOR_NOT_FOUND_ERROR. +def test_getMore_killed_cursor_error(collection): + """Test getMore on a killed cursor produces CursorNotFound.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + execute_command(collection, {"killCursors": collection.name, "cursors": [cursor_id]}) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertResult( + result, + error_code=CURSOR_NOT_FOUND_ERROR, + msg="getMore should produce CursorNotFound on a killed cursor", + raw_res=True, + ) + + +# Property [Cursor Lifecycle - Collection Drop]: getMore after the collection +# is dropped produces QUERY_PLAN_KILLED_ERROR. +def test_getMore_after_collection_drop(collection): + """Test getMore after collection drop produces QueryPlanKilled.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + collection.drop() + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertResult( + result, + error_code=QUERY_PLAN_KILLED_ERROR, + msg="getMore should produce QueryPlanKilled after collection is dropped", + raw_res=True, + ) + + +# Property [Cursor Lifecycle - Drop and Recreate]: getMore after the collection +# is dropped and recreated still produces QUERY_PLAN_KILLED_ERROR. +def test_getMore_after_drop_and_recreate(collection): + """Test getMore after drop and recreate still produces QueryPlanKilled.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + collection.drop() + collection.insert_many([{"_id": i, "new": True} for i in range(5)]) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertResult( + result, + error_code=QUERY_PLAN_KILLED_ERROR, + msg="getMore should produce QueryPlanKilled after drop and recreate", + raw_res=True, + ) + + +# Property [Cursor Lifecycle - Find batchSize Zero]: batchSize=0 on the +# originating find establishes a cursor without returning documents, and +# subsequent getMore returns documents. +def test_getMore_find_batch_size_zero(collection): + """Test getMore returns documents after find with batchSize=0.""" + collection.insert_many([{"_id": i} for i in range(5)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=0) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 3}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Eq([{"_id": 0}, {"_id": 1}, {"_id": 2}])}}, + msg="getMore should return documents after find with batchSize=0", + raw_res=True, + ) + + +# Property [Cursor Lifecycle - Tailable Stays Open]: a tailable cursor stays +# open and returns an empty batch when it reaches the end of data. +def test_getMore_tailable_stays_open_at_end(collection): + """Test tailable cursor stays open at end of data.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "awaitData": True, "batchSize": 10}) + gm = execute_command( + collection, + {"getMore": cursor_id, "collection": capped.name, "maxTimeMS": 0}, + ) + assertResult( + gm, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Eq([]), "id": Gte(1)}}, + msg="getMore on tailable cursor at end of data should return empty and stay open", + raw_res=True, + ) + + +# Property [Cursor Lifecycle - Tailable New Inserts]: a tailable cursor returns +# documents inserted after it reached the end of data. +def test_getMore_tailable_sees_new_inserts(collection): + """Test tailable cursor sees new inserts on subsequent getMore.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "awaitData": True, "batchSize": 10}) + # Insert new data after find consumed all existing docs in firstBatch. + capped.insert_one({"_id": 99, "new": True}) + gm = execute_command( + collection, + {"getMore": cursor_id, "collection": capped.name, "maxTimeMS": 100}, + ) + assertResult( + gm, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Eq([{"_id": 99, "new": True}])}}, + msg="getMore on tailable cursor should see new inserts", + raw_res=True, + ) + + +# Property [Cursor Lifecycle - Delete Visibility]: a find cursor does not +# return documents deleted before it reaches them; documents already returned +# are unaffected. +def test_getMore_deleted_docs_still_visible(collection): + """Test getMore skips documents deleted before the cursor reaches them.""" + collection.insert_many([{"_id": i, "v": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + # firstBatch returned _id 0,1; cursor is positioned at _id 2. + collection.delete_many({"_id": {"$gte": 5}}) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 10}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "cursor": { + "id": Eq(INT64_ZERO), + "nextBatch": Eq([{"_id": 2, "v": 2}, {"_id": 3, "v": 3}, {"_id": 4, "v": 4}]), + }, + }, + msg="getMore should skip documents deleted before the cursor reached them", + raw_res=True, + ) + + +# Property [Cursor Lifecycle - Insert Visibility]: documents inserted during +# iteration are visible in subsequent getMore calls. +def test_getMore_new_inserts_visible(collection): + """Test new documents inserted during iteration are visible in getMore.""" + collection.insert_many([{"_id": i} for i in range(5)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + collection.insert_many([{"_id": 100 + i, "new": True} for i in range(3)]) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 100}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "cursor": { + "nextBatch": Eq( + [ + {"_id": 2}, + {"_id": 3}, + {"_id": 4}, + {"_id": 100, "new": True}, + {"_id": 101, "new": True}, + {"_id": 102, "new": True}, + ] + ) + }, + }, + msg="getMore should see documents inserted during iteration", + raw_res=True, + ) + + +# Property [Cursor Lifecycle - Update Visibility]: a find cursor returns the +# updated value of a document modified before the cursor reaches it. +def test_getMore_updated_docs_show_new_value(collection): + """Test getMore returns the new value of a document updated before it is reached.""" + collection.insert_many([{"_id": i, "v": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + # firstBatch returned _id 0,1; cursor is positioned at _id 2. + collection.update_one({"_id": 4}, {"$set": {"v": 999}}) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 10}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "cursor": { + "id": Eq(INT64_ZERO), + "nextBatch": Eq([{"_id": i, "v": 999 if i == 4 else i} for i in range(2, 10)]), + }, + }, + msg="getMore should return the updated value of a document modified before it is reached", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_max_time_ms.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_max_time_ms.py new file mode 100644 index 000000000..2268776ed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_max_time_ms.py @@ -0,0 +1,387 @@ +"""Tests for getMore maxTimeMS behavior.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + CursorCommandContext, + CursorCommandTestCase, + open_cursor, + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gte, Len +from documentdb_tests.framework.target_collection import CappedCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_TRAILING_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT32_MAX, +) + +# Property [maxTimeMS Accepted Values]: numeric values that represent a whole +# number in the range 0 to 2147483647 are accepted as maxTimeMS on awaitData +# tailable cursors. +GETMORE_MAX_TIME_MS_ACCEPTED_TESTS: list[CursorCommandTestCase] = [ + # Negative zero variants accepted as 0. + CursorCommandTestCase( + "accepted_double_neg_zero", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": DOUBLE_NEGATIVE_ZERO, + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept double -0.0 as maxTimeMS", + ), + CursorCommandTestCase( + "accepted_decimal_neg_zero", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": DECIMAL128_NEGATIVE_ZERO, + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept Decimal128 -0 as maxTimeMS", + ), + CursorCommandTestCase( + "accepted_decimal_neg_zero_dot", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": Decimal128("-0.0"), + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept Decimal128 -0.0 as maxTimeMS", + ), + CursorCommandTestCase( + "accepted_decimal_neg_zero_exp", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": Decimal128("-0E+10"), + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept Decimal128 -0E+10 as maxTimeMS", + ), + # Decimal128 with trailing zeros accepted when value is whole number in range. + CursorCommandTestCase( + "accepted_decimal_0_0", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": Decimal128("0.0"), + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept Decimal128 0.0 as maxTimeMS", + ), + CursorCommandTestCase( + "accepted_decimal_1_0", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": DECIMAL128_TRAILING_ZERO, + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept Decimal128 1.0 as maxTimeMS", + ), + CursorCommandTestCase( + "accepted_decimal_1_00", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": Decimal128("1.00"), + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept Decimal128 1.00 as maxTimeMS", + ), + # Decimal128 in scientific notation accepted when result is whole number in range. + CursorCommandTestCase( + "accepted_decimal_1e2", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": Decimal128("1E+2"), + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept Decimal128 1E+2 as maxTimeMS", + ), + # Whole-number doubles accepted. + CursorCommandTestCase( + "accepted_double_0_0", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": DOUBLE_ZERO, + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept double 0.0 as maxTimeMS", + ), + CursorCommandTestCase( + "accepted_double_1_0", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": 1.0, + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept double 1.0 as maxTimeMS", + ), + CursorCommandTestCase( + "accepted_double_100_0", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": 100.0, + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept double 100.0 as maxTimeMS", + ), + # Range boundaries: 0 and max int32. + CursorCommandTestCase( + "accepted_zero", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": 0, + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept maxTimeMS=0", + ), + CursorCommandTestCase( + "accepted_int32_max", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": INT32_MAX, + }, + expected={"ok": Eq(1.0)}, + msg="getMore should accept maxTimeMS at max int32", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(GETMORE_MAX_TIME_MS_ACCEPTED_TESTS)) +def test_getMore_max_time_ms_accepted(collection, test_case: CursorCommandTestCase): + """Test getMore maxTimeMS accepted values on awaitData tailable cursor.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "awaitData": True, "batchSize": 2}) + ctx = CursorCommandContext.from_collection(capped, cursors=(cursor_id,)) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [maxTimeMS Semantic Constraint - Non-Tailable]: maxTimeMS on +# non-tailable cursors produces BAD_VALUE_ERROR. +GETMORE_MAX_TIME_MS_SEMANTIC_NON_TAILABLE_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "semantic_non_tailable", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": 100, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject maxTimeMS on a non-tailable cursor", + ), + CursorCommandTestCase( + "semantic_zero_non_tailable", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": 0, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject maxTimeMS=0 on a non-tailable cursor", + ), +] + + +@pytest.mark.parametrize( + "test_case", pytest_params(GETMORE_MAX_TIME_MS_SEMANTIC_NON_TAILABLE_TESTS) +) +def test_getMore_max_time_ms_semantic_non_tailable(collection, test_case: CursorCommandTestCase): + """Test getMore rejects maxTimeMS on non-tailable cursors.""" + collection.insert_many([{"_id": i} for i in range(5)]) + cursors = open_find_cursors( + collection, test_case.cursor_count, batch_size=test_case.find_batch_size + ) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [maxTimeMS Semantic Constraint - Tailable Without AwaitData]: maxTimeMS +# on tailable cursors without awaitData produces BAD_VALUE_ERROR. +GETMORE_MAX_TIME_MS_SEMANTIC_TAILABLE_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "semantic_tailable_no_await", + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "maxTimeMS": 100, + }, + error_code=BAD_VALUE_ERROR, + msg="getMore should reject maxTimeMS on a tailable cursor without awaitData", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(GETMORE_MAX_TIME_MS_SEMANTIC_TAILABLE_TESTS)) +def test_getMore_max_time_ms_semantic_tailable(collection, test_case: CursorCommandTestCase): + """Test getMore rejects maxTimeMS on tailable cursors without awaitData.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "batchSize": 2}) + ctx = CursorCommandContext.from_collection(capped, cursors=(cursor_id,)) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [maxTimeMS Null Bypass]: maxTimeMS=null bypasses the semantic check +# that otherwise rejects maxTimeMS on non-awaitData cursors. +def test_getMore_max_time_ms_null_bypasses_semantic_check(collection): + """Test getMore maxTimeMS=null bypasses the non-awaitData semantic check.""" + collection.insert_many([{"_id": i} for i in range(5)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "maxTimeMS": None}, + ) + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="getMore should succeed with maxTimeMS=null on non-awaitData cursor", + raw_res=True, + ) + + +# Property [maxTimeMS Per-Call]: maxTimeMS is accepted independently on each +# successive getMore call on the same cursor. +def test_getMore_max_time_ms_varies_between_calls(collection): + """Test getMore accepts a different maxTimeMS on each successive call on the same cursor.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "awaitData": True, "batchSize": 2}) + r1 = execute_command( + collection, + {"getMore": cursor_id, "collection": capped.name, "maxTimeMS": 50}, + ) + cursor_id2 = r1["cursor"]["id"] + r2 = execute_command( + collection, + {"getMore": cursor_id2, "collection": capped.name, "maxTimeMS": 200}, + ) + assertResult( + r2, + expected={"ok": Eq(1.0)}, + msg="getMore should accept a different maxTimeMS on a successive call", + raw_res=True, + ) + + +# Property [maxTimeMS With Data]: getMore accepts maxTimeMS and returns the +# available data when documents are present. +def test_getMore_max_time_ms_returns_immediately_with_data(collection): + """Test getMore accepts maxTimeMS and returns available data.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "awaitData": True, "batchSize": 2}) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": capped.name, "maxTimeMS": 5_000}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(3)}}, + msg="getMore should accept maxTimeMS and return available data", + raw_res=True, + ) + + +# Property [maxTimeMS No-Data Behavior]: on a tailable awaitData cursor with no +# data available, maxTimeMS returns an empty batch and keeps the cursor open. +def test_getMore_max_time_ms_no_data_empty_batch(collection): + """Test getMore with maxTimeMS returns empty batch and keeps cursor open when no data.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "awaitData": True, "batchSize": 10}) + execute_command(collection, {"getMore": cursor_id, "collection": capped.name, "maxTimeMS": 0}) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": capped.name, "maxTimeMS": 0}, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Eq([]), "id": Gte(1)}}, + msg="getMore with maxTimeMS should return empty batch and keep cursor open when no data", + raw_res=True, + ) + + +# Property [maxTimeMS Timeout Retains Cursor]: a getMore that times out with no +# data keeps the tailable cursor open for subsequent getMore calls. +def test_getMore_max_time_ms_timeout_retains_cursor(collection): + """Test a getMore timeout on a tailable cursor keeps it open for subsequent calls.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "awaitData": True, "batchSize": 10}) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": capped.name, "maxTimeMS": 0}, + ) + cursor_id = result["cursor"]["id"] + result2 = execute_command( + collection, + {"getMore": cursor_id, "collection": capped.name, "maxTimeMS": 100}, + ) + assertResult( + result2, + expected={"ok": Eq(1.0), "cursor": {"id": Gte(1)}}, + msg="getMore should keep a tailable cursor open after a timeout", + raw_res=True, + ) + + +# Property [maxTimeMS Omitted AwaitData]: getMore succeeds when maxTimeMS is +# omitted on an awaitData tailable cursor. +def test_getMore_missing_max_time_ms_await_data(collection): + """Test getMore succeeds with maxTimeMS omitted on awaitData tailable cursor.""" + capped = CappedCollection().resolve(collection.database, collection) + capped.insert_many([{"_id": i} for i in range(5)]) + cursor_id = open_cursor(capped, {"tailable": True, "awaitData": True, "batchSize": 2}) + result = execute_command(collection, {"getMore": cursor_id, "collection": capped.name}) + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="getMore should succeed when maxTimeMS is omitted on awaitData tailable cursor", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_namespace.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_namespace.py new file mode 100644 index 000000000..408d66043 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_namespace.py @@ -0,0 +1,86 @@ +"""Tests for getMore namespace matching.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + CursorCommandContext, + CursorCommandTestCase, + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import UNAUTHORIZED_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Len + +# Property [Namespace Match Success]: getMore succeeds when the collection +# parameter exactly matches the find cursor's bound namespace. +GETMORE_NAMESPACE_MATCH_FIND_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "match_find", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: {"getMore": ctx.cursors[0], "collection": ctx.collection}, + expected={"ok": Eq(1.0), "cursor": {"nextBatch": Len(3)}}, + msg="getMore should succeed when collection matches find cursor namespace", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(GETMORE_NAMESPACE_MATCH_FIND_TESTS)) +def test_getMore_namespace_match_find(collection, test_case: CursorCommandTestCase): + """Test getMore succeeds with exact collection name match for find cursor.""" + collection.insert_many([{"_id": i} for i in range(5)]) + cursors = open_find_cursors( + collection, test_case.cursor_count, batch_size=test_case.find_batch_size + ) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) + + +# Property [Namespace Case Sensitivity]: getMore requires an exact +# case-sensitive match for the collection name. +def test_getMore_namespace_case_sensitive(collection): + """Test getMore requires exact case-sensitive match for collection name.""" + collection.insert_many([{"_id": i} for i in range(5)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name.upper()}, + ) + assertResult( + result, + error_code=UNAUTHORIZED_ERROR, + msg="getMore should reject uppercase variant of actual collection name", + raw_res=True, + ) + + +# Property [Namespace View Binding]: a view cursor is bound to the view name; +# getMore addressed to the underlying collection is rejected. +def test_getMore_namespace_view_bound_to_view_name(collection): + """Test view cursors are bound to the view name, not the underlying collection.""" + collection.insert_many([{"_id": i} for i in range(5)]) + view_name = f"{collection.name}_view" + execute_command(collection, {"create": view_name, "viewOn": collection.name, "pipeline": []}) + find_result = execute_command(collection, {"find": view_name, "batchSize": 2}) + cursor_id = find_result["cursor"]["id"] + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertResult( + result, + error_code=UNAUTHORIZED_ERROR, + msg="getMore should reject underlying collection name for a view cursor", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_null_fields.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_null_fields.py new file mode 100644 index 000000000..80b8f2a4c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_null_fields.py @@ -0,0 +1,97 @@ +"""Tests for getMore field presence and recognition behavior.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + CursorCommandContext, + CursorCommandTestCase, + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + MISSING_FIELD_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Unrecognized Fields]: any field not in the getMore command (whether +# a find-only field or an arbitrary unknown field) produces +# UNRECOGNIZED_COMMAND_FIELD_ERROR. +GETMORE_UNRECOGNIZED_FIELD_ERROR_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "unrecognized_find_only_field", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "filter": {"x": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="getMore should reject a find-only field", + ), + CursorCommandTestCase( + "unrecognized_arbitrary_field", + cursor_count=0, + command=lambda ctx: { + "getMore": Int64(1), + "collection": ctx.collection, + "unknownField": 123, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="getMore should reject an arbitrary unknown field", + ), +] + +# Property [Null Required Fields Error]: getMore and collection fields set to +# null are treated as missing required fields and produce MISSING_FIELD_ERROR. +GETMORE_NULL_REQUIRED_FIELD_ERROR_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "null_getmore_field", + cursor_count=0, + command=lambda ctx: {"getMore": None, "collection": ctx.collection}, + error_code=MISSING_FIELD_ERROR, + msg="getMore should reject null getMore field as missing required field", + ), + CursorCommandTestCase( + "null_collection_field", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: {"getMore": ctx.cursors[0], "collection": None}, + error_code=MISSING_FIELD_ERROR, + msg="getMore should reject null collection field as missing required field", + ), + CursorCommandTestCase( + "missing_collection_field", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: {"getMore": ctx.cursors[0]}, + error_code=MISSING_FIELD_ERROR, + msg="getMore should reject omitted collection field as missing required field", + ), +] + +GETMORE_NULL_TESTS: list[CursorCommandTestCase] = ( + GETMORE_UNRECOGNIZED_FIELD_ERROR_TESTS + GETMORE_NULL_REQUIRED_FIELD_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(GETMORE_NULL_TESTS)) +def test_getMore_field_presence(collection, test_case: CursorCommandTestCase): + """Test getMore field presence and recognition behavior.""" + collection.insert_many([{"_id": i, "v": i} for i in range(5)]) + cursors = open_find_cursors( + collection, test_case.cursor_count, batch_size=test_case.find_batch_size + ) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_response.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_response.py new file mode 100644 index 000000000..9d0441441 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_response.py @@ -0,0 +1,65 @@ +"""Tests for getMore response structure.""" + +from __future__ import annotations + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Eq, IsType +from documentdb_tests.framework.test_constants import INT64_ZERO + + +# Property [Response Structure]: the getMore response contains ok (double), +# cursor.id (long), cursor.ns (string), and cursor.nextBatch (array). +def test_getMore_response_structure(collection): + """Test getMore response structure and field types.""" + collection.insert_many([{"_id": i} for i in range(5)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 2}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "cursor": { + "id": IsType("long"), + "ns": IsType("string"), + "nextBatch": IsType("array"), + }, + }, + msg=( + "getMore response should contain ok, cursor.id," + " cursor.ns, and cursor.nextBatch with correct types" + ), + raw_res=True, + ) + + +# Property [Exhausted Cursor Response]: when the cursor is exhausted, +# cursor.id is 0 and nextBatch is empty. +def test_getMore_exhausted_cursor_response(collection): + """Test getMore response when cursor is exhausted.""" + collection.insert_many([{"_id": i} for i in range(4)]) + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + gm1 = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name, "batchSize": 2}, + ) + cursor_id2 = gm1["cursor"]["id"] + result = execute_command( + collection, + {"getMore": cursor_id2, "collection": collection.name, "batchSize": 2}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "cursor": {"id": Eq(INT64_ZERO), "nextBatch": Eq([])}, + }, + msg="getMore should return cursor.id=0 and empty nextBatch when exhausted", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_session.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_session.py new file mode 100644 index 000000000..950fdb647 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_session.py @@ -0,0 +1,146 @@ +"""Tests for getMore session behavior.""" + +from __future__ import annotations + +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import CURSOR_SESSION_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Eq + + +# Property [Session Mismatch - Different Explicit Session]: getMore with a +# different explicit session than the cursor's produces +# CURSOR_SESSION_MISMATCH_ERROR. +def test_getMore_session_mismatch_different_session(collection): + """Test getMore with a different explicit session produces session mismatch error.""" + collection.insert_many([{"_id": i} for i in range(10)]) + client = collection.database.client + session_a = client.start_session() + session_b = client.start_session() + try: + find_result = execute_command( + collection, {"find": collection.name, "batchSize": 2}, session=session_a + ) + cursor_id = find_result["cursor"]["id"] + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + session=session_b, + ) + assertResult( + result, + error_code=CURSOR_SESSION_MISMATCH_ERROR, + msg="getMore should reject a different explicit session than the cursor's", + raw_res=True, + ) + finally: + session_a.end_session() + session_b.end_session() + + +# Property [Session Match - Same Session]: getMore with the same explicit +# session as the cursor succeeds. +def test_getMore_session_same_session_succeeds(collection): + """Test getMore with the same explicit session succeeds.""" + collection.insert_many([{"_id": i} for i in range(10)]) + client = collection.database.client + session_a = client.start_session() + try: + find_result = execute_command( + collection, {"find": collection.name, "batchSize": 2}, session=session_a + ) + cursor_id = find_result["cursor"]["id"] + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + session=session_a, + ) + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="getMore should succeed with the same explicit session", + raw_res=True, + ) + finally: + session_a.end_session() + + +# Property [Session - Implicit Cursor, Explicit getMore]: getMore with an +# explicit session on an implicit-session cursor succeeds. +def test_getMore_session_implicit_cursor_explicit_getmore(collection): + """Test getMore with explicit session on implicit session cursor succeeds.""" + collection.insert_many([{"_id": i} for i in range(10)]) + client = collection.database.client + (cursor_id,) = open_find_cursors(collection, 1, batch_size=2) + session_b = client.start_session() + try: + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + session=session_b, + ) + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="getMore should succeed with explicit session on implicit session cursor", + raw_res=True, + ) + finally: + session_b.end_session() + + +# Property [Session - Explicit Cursor, Implicit getMore]: getMore without an +# explicit session on an explicit-session cursor produces +# CURSOR_SESSION_MISMATCH_ERROR. +def test_getMore_session_explicit_cursor_implicit_getmore(collection): + """Test getMore without explicit session on explicit session cursor produces error.""" + collection.insert_many([{"_id": i} for i in range(10)]) + client = collection.database.client + session_a = client.start_session() + try: + find_result = execute_command( + collection, {"find": collection.name, "batchSize": 2}, session=session_a + ) + cursor_id = find_result["cursor"]["id"] + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertResult( + result, + error_code=CURSOR_SESSION_MISMATCH_ERROR, + msg=( + "getMore should reject implicit session when cursor was" + " created with explicit session" + ), + raw_res=True, + ) + finally: + session_a.end_session() + + +# Property [Session Survives End]: getMore succeeds after the originating +# session has ended. +def test_getMore_session_survives_session_end(collection): + """Test getMore succeeds after the originating session has ended.""" + collection.insert_many([{"_id": i} for i in range(10)]) + client = collection.database.client + session_a = client.start_session() + find_result = execute_command( + collection, {"find": collection.name, "batchSize": 2}, session=session_a + ) + cursor_id = find_result["cursor"]["id"] + session_a.end_session() + result = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertResult( + result, + expected={"ok": Eq(1.0)}, + msg="getMore should succeed after the originating session has ended", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_term.py b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_term.py new file mode 100644 index 000000000..0035c9135 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/getMore/test_getMore_term.py @@ -0,0 +1,102 @@ +"""Tests for the getMore term field (used by replication).""" + +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.cursors.commands.utils.cursor_test_case import ( + CursorCommandContext, + CursorCommandTestCase, + open_find_cursors, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Term Field Accepted]: the "term" field is silently accepted when +# its value is Int64 or null. +GETMORE_TERM_FIELD_ACCEPTED_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "term_int64", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "term": Int64(1), + }, + expected={"ok": Eq(1.0)}, + msg="getMore should silently accept 'term' field with Int64 value", + ), + CursorCommandTestCase( + "term_null", + cursor_count=1, + find_batch_size=2, + command=lambda ctx: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "term": None, + }, + expected={"ok": Eq(1.0)}, + msg="getMore should silently accept 'term' field with null value", + ), +] + +# Property [Term Field Type Rejection]: all non-Int64, non-null types for the +# term field produce TYPE_MISMATCH_ERROR. +GETMORE_TERM_FIELD_TYPE_ERROR_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + f"term_type_{tid}", + cursor_count=1, + find_batch_size=2, + command=lambda ctx, v=val: { + "getMore": ctx.cursors[0], + "collection": ctx.collection, + "term": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"getMore should reject {tid} as term field", + ) + for tid, val in [ + ("int32", 42), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("string", "hello"), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2023, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +GETMORE_TERM_TESTS = GETMORE_TERM_FIELD_ACCEPTED_TESTS + GETMORE_TERM_FIELD_TYPE_ERROR_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(GETMORE_TERM_TESTS)) +def test_getMore_term(collection, test_case: CursorCommandTestCase): + """Test getMore term field acceptance and type rejection.""" + collection.insert_many([{"_id": i, "v": i} for i in range(5)]) + cursors = open_find_cursors( + collection, test_case.cursor_count, batch_size=test_case.find_batch_size + ) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test_case.build_command(ctx)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + raw_res=True, + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 0b9d2148a..777741666 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -331,6 +331,7 @@ TRIM_INPUT_TYPE_ERROR = 50699 TRIM_CHARS_TYPE_ERROR = 50700 TO_TYPE_ARITY_ERROR = 50723 +CURSOR_SESSION_MISMATCH_ERROR = 50738 SUBSTR_NEGATIVE_START_ERROR = 50752 CLUSTERED_NAN_DUPLICATE_ERROR = 50819 CLUSTERED_INFINITY_DUPLICATE_ERROR = 50826 diff --git a/documentdb_tests/framework/executor.py b/documentdb_tests/framework/executor.py index de92b0a14..4df8464aa 100644 --- a/documentdb_tests/framework/executor.py +++ b/documentdb_tests/framework/executor.py @@ -10,7 +10,7 @@ TZ_AWARE_CODEC = CodecOptions(tz_aware=True, tzinfo=timezone.utc) -def execute_command(collection, command: Dict, codec_options=TZ_AWARE_CODEC) -> Any: +def execute_command(collection, command: Dict, codec_options=TZ_AWARE_CODEC, session=None) -> Any: """ Execute a DocumentDB command and return result or exception. @@ -19,13 +19,14 @@ def execute_command(collection, command: Dict, codec_options=TZ_AWARE_CODEC) -> command: Command to execute via runCommand codec_options: CodecOptions for result decoding. Defaults to UTC-aware datetime decoding. + session: Optional ClientSession for session-aware commands. Returns: Result if successful, Exception if failed """ try: db = collection.database - result = db.command(command, codec_options=codec_options) + result = db.command(command, codec_options=codec_options, session=session) return result except Exception as e: return e