From ce686f0d187e9d1629835cf49b3615eb4c6ddc4e Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 26 May 2026 09:18:48 -0700 Subject: [PATCH 1/3] Add killCursors tests Signed-off-by: Daniel Frankcom --- .../cursors/commands/killCursors/__init__.py | 0 .../killCursors/test_killCursors_comment.py | 81 ++++ .../test_killCursors_cursors_acceptance.py | 141 ++++++ .../test_killCursors_cursors_type.py | 197 ++++++++ .../test_killCursors_field_type.py | 76 +++ .../killCursors/test_killCursors_lifecycle.py | 457 ++++++++++++++++++ .../killCursors/test_killCursors_maxtimems.py | 188 +++++++ .../killCursors/test_killCursors_options.py | 167 +++++++ .../test_killCursors_readconcern.py | 371 ++++++++++++++ .../killCursors/test_killCursors_response.py | 186 +++++++ .../commands/killCursors/utils/__init__.py | 0 .../killCursors/utils/cursor_test_case.py | 54 +++ documentdb_tests/framework/error_codes.py | 1 + documentdb_tests/framework/executor.py | 8 +- 14 files changed, 1922 insertions(+), 5 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_comment.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_cursors_acceptance.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_cursors_type.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_field_type.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_maxtimems.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_options.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_readconcern.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_response.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/cursor_test_case.py diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/__init__.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_comment.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_comment.py new file mode 100644 index 000000000..ceb4657c8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_comment.py @@ -0,0 +1,81 @@ +"""Tests for killCursors comment field type acceptance.""" + +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.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [comment Field Universal Type Acceptance]: all BSON types +# representable by pymongo are accepted for the comment field without +# restriction. +KILLCURSORS_COMMENT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "comment": v, + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg=f"killCursors should accept {tid} 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(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_COMMENT_TYPE_TESTS)) +def test_killCursors_comment(collection, test): + """Test killCursors comment field type acceptance.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_cursors_acceptance.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_cursors_acceptance.py new file mode 100644 index 000000000..5c32cba6f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_cursors_acceptance.py @@ -0,0 +1,141 @@ +"""Tests for killCursors cursors field acceptance.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, + INT64_ZERO, +) + +# Property [Int64 Boundary Values]: Int64 boundary values are accepted in +# the cursors array and reported in cursorsNotFound when they do not match +# an active cursor. +KILLCURSORS_INT64_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"boundary_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [v], + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [val], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg=f"killCursors should accept Int64 {tid}", + ) + for tid, val in [ + ("zero", INT64_ZERO), + ("positive_one", Int64(1)), + ("negative_one", Int64(-1)), + ("int32_max", Int64(INT32_MAX)), + ("int32_min", Int64(INT32_MIN)), + ("int32_max_plus_one", Int64(INT32_OVERFLOW)), + ("int32_min_minus_one", Int64(INT32_UNDERFLOW)), + ("int64_max", INT64_MAX), + ("int64_min", INT64_MIN), + ("int64_max_minus_one", INT64_MAX_MINUS_1), + ("int64_min_plus_one", INT64_MIN_PLUS_1), + ] +] + +# Property [Null Element Silent Skip]: null elements in the cursors array +# are silently skipped without triggering type-validation rejection, and +# valid Int64 elements alongside nulls are still processed normally. +KILLCURSORS_NULL_ELEMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_element_interspersed_with_valid", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [None, Int64(1), None, Int64(2), None], + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1), Int64(2)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should process valid Int64 elements and skip interspersed nulls", + ), +] + +# Property [cursors Field Empty Array]: an empty cursors array is a valid +# no-op that succeeds with ok 1.0 and all response arrays empty. +KILLCURSORS_EMPTY_ARRAY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_array_noop", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [], + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should succeed as a no-op with an empty cursors array", + ), +] + +# Property [cursors Field Array Size]: large arrays are accepted, +# limited only by the 16MB BSON document size. +KILLCURSORS_LARGE_ARRAY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "large_array_10k", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(i) for i in range(10_000)], + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(i) for i in range(10_000)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should accept a cursors array with 10,000 elements", + ), +] + +KILLCURSORS_CURSORS_ACCEPTANCE_TESTS: list[CommandTestCase] = ( + KILLCURSORS_INT64_BOUNDARY_TESTS + + KILLCURSORS_NULL_ELEMENT_TESTS + + KILLCURSORS_EMPTY_ARRAY_TESTS + + KILLCURSORS_LARGE_ARRAY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_CURSORS_ACCEPTANCE_TESTS)) +def test_killCursors_cursors_acceptance(collection, test): + """Test killCursors cursors field acceptance.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_cursors_type.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_cursors_type.py new file mode 100644 index 000000000..3cb1c3a76 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_cursors_type.py @@ -0,0 +1,197 @@ +"""Tests for killCursors cursors field type rejection.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [cursors Field Type Rejection]: all non-array types for the +# cursors field are rejected. +KILLCURSORS_CURSORS_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"cursors_type_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killCursors should reject {tid} as cursors field", + ) + for tid, val in [ + ("string", "not_an_array"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("bool", True), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ] +] + +# Property [cursors Element Type Rejection]: all non-Int64 types inside +# the cursors array are rejected with no numeric coercion. +KILLCURSORS_ELEMENT_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"element_type_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [v], + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killCursors should reject {tid} element in cursors array", + ) + for tid, val in [ + ("int32", 42), + ("double_whole", 3.0), + ("double_nan", FLOAT_NAN), + ("double_infinity", FLOAT_INFINITY), + ("bool", True), + ("string", "not_a_cursor"), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("binary_uuid", Binary(b"\x00" * 16, subtype=4)), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128", Decimal128("1")), + ("nested_array", [Int64(1)]), + ] +] + [ + CommandTestCase( + "element_type_null_before_invalid", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [None, "bad"], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="killCursors should reject invalid element after null in cursors array", + ), + CommandTestCase( + "element_type_two_nulls_before_invalid", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [None, None, "bad"], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="killCursors should reject invalid element after multiple nulls in cursors array", + ), + CommandTestCase( + "element_type_valid_then_null_then_invalid", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1), None, "bad"], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="killCursors should reject invalid element after valid and null in cursors array", + ), + CommandTestCase( + "element_type_first_element_invalid", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": ["bad", 42], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="killCursors should reject array when first element has wrong type", + ), + CommandTestCase( + "element_type_second_element_invalid", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1), "bad", 42], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="killCursors should reject array when second element has wrong type", + ), + CommandTestCase( + "element_type_multiple_invalid", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1), 42, "bad"], + }, + error_code=TYPE_MISMATCH_ERROR, + msg="killCursors should reject array with multiple invalid elements", + ), +] + +# Property [cursors Field Missing or Null]: omitting the cursors field +# or setting it to null is rejected because it is a required field. +KILLCURSORS_CURSORS_MISSING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "cursors_missing_omitted", + command=lambda ctx: { + "killCursors": ctx.collection, + }, + error_code=MISSING_FIELD_ERROR, + msg="killCursors should reject command when cursors field is omitted", + ), + CommandTestCase( + "cursors_missing_null", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": None, + }, + error_code=MISSING_FIELD_ERROR, + msg="killCursors should reject null cursors field as missing", + ), +] + +KILLCURSORS_CURSORS_TYPE_TESTS: list[CommandTestCase] = ( + KILLCURSORS_CURSORS_TYPE_ERROR_TESTS + + KILLCURSORS_ELEMENT_TYPE_ERROR_TESTS + + KILLCURSORS_CURSORS_MISSING_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_CURSORS_TYPE_TESTS)) +def test_killCursors_cursors_type(collection, test): + """Test killCursors cursors field type rejection.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_field_type.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_field_type.py new file mode 100644 index 000000000..3146b7573 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_field_type.py @@ -0,0 +1,76 @@ +"""Tests for killCursors field type and value rejection.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import INVALID_NAMESPACE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [killCursors Field Type Rejection]: all non-string types for +# the killCursors field are rejected. +KILLCURSORS_FIELD_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"field_type_{tid}", + command=lambda ctx, v=val: { + "killCursors": v, + "cursors": [Int64(1)], + }, + error_code=INVALID_NAMESPACE_ERROR, + msg=f"killCursors should reject {tid} as collection name", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool", True), + ("null", None), + ("array", ["a"]), + ("empty_array", []), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary_subtype0", Binary(b"\x01\x02\x03")), + ("binary_subtype3", Binary(b"\x01\x02\x03", subtype=3)), + ("binary_uuid", Binary(b"\x00" * 16, subtype=4)), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_FIELD_TYPE_ERROR_TESTS)) +def test_killCursors_field_type(collection, test): + """Test killCursors field type rejection.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py new file mode 100644 index 000000000..fadded11b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py @@ -0,0 +1,457 @@ +"""Tests for killCursors cursor lifecycle and behavior.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.cursors.commands.killCursors.utils.cursor_test_case import ( # noqa: E501 + CursorCommandContext, + CursorCommandTestCase, + open_find_cursors, +) +from documentdb_tests.framework import fixtures +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertResult, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import CURSOR_NOT_FOUND_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Core Behavior - Kill Active Cursors]: active cursors from +# find and aggregate are killed and reported in cursorsKilled. +KILLCURSORS_CORE_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "kill_single_find_cursor", + docs=[{"_id": i} for i in range(10)], + cursor_count=1, + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [ctx.cursors[0]], + }, + expected=lambda ctx: { + "cursorsKilled": [ctx.cursors[0]], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill a single find cursor", + ), + CursorCommandTestCase( + "batch_kill_50_cursors", + docs=[{"_id": i} for i in range(100)], + cursor_count=50, + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": list(ctx.cursors), + }, + expected=lambda ctx: { + "cursorsKilled": list(ctx.cursors), + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill all 50 cursors in a single batch", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_CORE_TESTS)) +def test_killCursors_lifecycle(database_client, collection, test): + """Test killCursors cursor lifecycle.""" + collection = test.prepare(database_client, collection) + cursors = open_find_cursors(collection, test.cursor_count) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_killCursors_aggregate_cursor(collection): + """Test killCursors kills an aggregate cursor.""" + collection.insert_many([{"_id": i} for i in range(10)]) + res = execute_command( + collection, + {"aggregate": collection.name, "pipeline": [], "cursor": {"batchSize": 1}}, + ) + cursor_id = res["cursor"]["id"] + result = execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor from aggregate", + raw_res=True, + ) + + +def test_killCursors_list_collections_cursor(database_client, collection): + """Test killCursors kills a listCollections cursor.""" + db = database_client + for i in range(10): + db.create_collection(f"lc_kill_{i}") + res = execute_command( + collection, + {"listCollections": 1, "cursor": {"batchSize": 1}}, + ) + cursor_id = res["cursor"]["id"] + result = execute_command( + collection, + {"killCursors": "$cmd.listCollections", "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor from listCollections", + raw_res=True, + ) + + +def test_killCursors_list_indexes_cursor(collection): + """Test killCursors kills a listIndexes cursor.""" + collection.insert_many([{"_id": i} for i in range(10)]) + for i in range(10): + collection.create_index([(f"field_{i}", 1)]) + res = execute_command( + collection, + {"listIndexes": collection.name, "cursor": {"batchSize": 1}}, + ) + cursor_id = res["cursor"]["id"] + result = execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor from listIndexes", + raw_res=True, + ) + + +def test_killCursors_tailable_cursor(database_client): + """Test killCursors kills a tailable cursor.""" + db = database_client + db.create_collection("capped_for_kill", capped=True, size=100_000) + capped = db["capped_for_kill"] + capped.insert_many([{"_id": i} for i in range(10)]) + res = execute_command( + capped, + {"find": "capped_for_kill", "batchSize": 1, "tailable": True}, + ) + cursor_id = res["cursor"]["id"] + result = execute_command( + capped, + {"killCursors": "capped_for_kill", "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor from tailable find", + raw_res=True, + ) + + +# Property [Core Behavior - getMore After Kill]: after a cursor is +# killed, a getMore using that cursor ID produces CursorNotFound. +def test_killCursors_getmore_after_kill(collection): + """Test getMore fails after cursor is killed.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1) + execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + getmore = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertFailureCode( + getmore, + CURSOR_NOT_FOUND_ERROR, + msg="killCursors should kill cursor so getMore fails with CursorNotFound", + ) + + +# Property [Core Behavior - Sibling Cursors Unaffected]: killing one +# cursor does not affect sibling cursors from the same query. +def test_killCursors_sibling_unaffected(collection): + """Test killing one cursor does not affect siblings.""" + collection.insert_many([{"_id": i} for i in range(10)]) + cursor_a, cursor_b = open_find_cursors(collection, 2) + execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_a]}, + ) + getmore = execute_command( + collection, + {"getMore": cursor_b, "collection": collection.name}, + ) + assertSuccessPartial(getmore, {"ok": 1.0}, msg="killCursors should not affect sibling cursors") + + +# Property [cursors Element Type Rejection - Atomic Pre-Check]: if any +# element has a wrong type, the entire command fails and no cursors are +# killed, even valid cursors preceding the invalid element. +def test_killCursors_atomic_precheck_cursor_survives(collection): + """Test valid cursors survive when type validation fails.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1) + execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id, "bad"]}, + ) + getmore = execute_command( + collection, + {"getMore": cursor_id, "collection": collection.name}, + ) + assertSuccessPartial( + getmore, + {"ok": 1.0}, + msg="killCursors should not kill valid cursors when type validation fails", + ) + + +# Property [Idempotency]: killing the same cursor twice reports it in +# cursorsKilled on the first attempt and cursorsNotFound on the second. +def test_killCursors_idempotency(collection): + """Test killing the same cursor twice.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1) + + execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + + result = execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [cursor_id], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should report cursor in cursorsNotFound on second kill", + raw_res=True, + ) + + +# Property [Mixed Cursor States]: when the cursors array contains a mix +# of active, already-killed, and non-existent IDs, each is correctly +# categorized into cursorsKilled or cursorsNotFound. +def test_killCursors_mixed_states(collection): + """Test mixed active, killed, and non-existent cursor IDs.""" + collection.insert_many([{"_id": i} for i in range(10)]) + + active_cursor, pre_killed_cursor = open_find_cursors(collection, 2) + + execute_command( + collection, + {"killCursors": collection.name, "cursors": [pre_killed_cursor]}, + ) + + nonexistent_cursor = Int64(999_999_999) + + result = execute_command( + collection, + { + "killCursors": collection.name, + "cursors": [active_cursor, pre_killed_cursor, nonexistent_cursor], + }, + ) + assertResult( + result, + expected={ + "cursorsKilled": [active_cursor], + "cursorsNotFound": [pre_killed_cursor, nonexistent_cursor], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should categorize active, killed, and non-existent IDs correctly", + raw_res=True, + ) + + +# Property [Cursor Lifecycle After Metadata Changes]: a cursor remains +# killable after the source collection is dropped, renamed, recreated, +# or the source database is dropped. +def test_killCursors_after_drop_collection(database_client, collection): + """Test cursor remains killable after source collection is dropped.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1) + + database_client.drop_collection(collection.name) + + result = execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor after collection is dropped", + raw_res=True, + ) + + +def test_killCursors_after_rename_collection( + engine_client, database_client, collection, register_db_cleanup +): + """Test cursor remains killable after source collection is renamed.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1) + + renamed = collection.name + "_renamed" + register_db_cleanup(f"{database_client.name}.{renamed}") + engine_client.admin.command( + { + "renameCollection": f"{database_client.name}.{collection.name}", + "to": f"{database_client.name}.{renamed}", + } + ) + + result = execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor after collection is renamed", + raw_res=True, + ) + + +def test_killCursors_after_recreate_collection(database_client, collection): + """Test cursor remains killable after source collection is recreated.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1) + + database_client.drop_collection(collection.name) + database_client[collection.name].insert_one({"_id": 1, "new": True}) + + result = execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor after collection is recreated", + raw_res=True, + ) + + +def test_killCursors_after_drop_database(engine_client, database_client, collection): + """Test cursor remains killable after source database is dropped.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1) + + engine_client.drop_database(database_client.name) + + result = execute_command( + collection, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor after database is dropped", + raw_res=True, + ) + + +# Property [Cross-Connection Behavior]: a cursor created on one +# connection can be killed from a different connection to the same server. +def test_killCursors_cross_connection(request, database_client, collection): + """Test killing a cursor from a different connection.""" + collection.insert_many([{"_id": i} for i in range(10)]) + (cursor_id,) = open_find_cursors(collection, 1) + + second_client = fixtures.create_engine_client(request.config.connection_string, "second") + try: + second_coll = second_client[database_client.name][collection.name] + result = execute_command( + second_coll, + {"killCursors": collection.name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor from a different connection", + raw_res=True, + ) + finally: + second_client.close() diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_maxtimems.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_maxtimems.py new file mode 100644 index 000000000..654262714 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_maxtimems.py @@ -0,0 +1,188 @@ +"""Tests for killCursors maxTimeMS field.""" + +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.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_MAX, + INT32_OVERFLOW, + INT64_ZERO, +) + +# Property [maxTimeMS Acceptance]: maxTimeMS accepts values at both +# boundaries of the valid range across all numeric types. +KILLCURSORS_MAXTIMEMS_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"maxtimems_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "maxTimeMS": v, + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg=f"killCursors should accept {tid} maxTimeMS", + ) + for tid, val in [ + # Lower boundary (0) in all representations. + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + # Upper boundary (INT32_MAX) in all representations. + ("int32_max", INT32_MAX), + ("int64_max", Int64(INT32_MAX)), + ("double_max", float(INT32_MAX)), + ("decimal128_max", Decimal128(str(INT32_MAX))), + ] +] + +# Property [maxTimeMS Type Rejection]: all non-numeric, non-null BSON +# types for maxTimeMS are rejected. +KILLCURSORS_MAXTIMEMS_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"maxtimems_type_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "maxTimeMS": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killCursors should reject {tid} for maxTimeMS", + ) + for tid, val in [ + ("bool_true", True), + ("bool_false", False), + ("string", "100"), + ("object", {"a": 1}), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"\x01")), + ("binary_uuid", Binary(b"\x00" * 16, subtype=4)), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [maxTimeMS Value Rejection]: values outside the valid range +# or not representable as whole integers are rejected. +KILLCURSORS_MAXTIMEMS_VALUE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"maxtimems_value_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "maxTimeMS": v, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"killCursors should reject {tid} maxTimeMS", + ) + for tid, val in [ + # Fractional values. + ("fractional_double", 1.5), + ("fractional_decimal128", DECIMAL128_ONE_AND_HALF), + # Double NaN and Infinity. + ("double_nan", FLOAT_NAN), + ("double_negative_nan", FLOAT_NEGATIVE_NAN), + ("double_positive_infinity", FLOAT_INFINITY), + ("double_negative_infinity", FLOAT_NEGATIVE_INFINITY), + # Decimal128 NaN and Infinity. + ("decimal128_nan", DECIMAL128_NAN), + ("decimal128_negative_nan", DECIMAL128_NEGATIVE_NAN), + ("decimal128_positive_infinity", DECIMAL128_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + [ + CommandTestCase( + f"maxtimems_value_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "maxTimeMS": v, + }, + error_code=BAD_VALUE_ERROR, + msg=f"killCursors should reject {tid} maxTimeMS", + ) + for tid, val in [ + # Below lower boundary. + ("int32_negative", -1), + ("int64_negative", Int64(-1)), + ("double_negative", -1.0), + ("decimal128_negative", Decimal128("-1")), + # Above upper boundary. + ("int32_overflow", INT32_OVERFLOW), + ("int64_overflow", Int64(INT32_OVERFLOW)), + ("double_overflow", float(INT32_OVERFLOW)), + ("decimal128_overflow", Decimal128(str(INT32_OVERFLOW))), + ] +] + +KILLCURSORS_MAXTIMEMS_TESTS: list[CommandTestCase] = ( + KILLCURSORS_MAXTIMEMS_ACCEPTANCE_TESTS + + KILLCURSORS_MAXTIMEMS_TYPE_ERROR_TESTS + + KILLCURSORS_MAXTIMEMS_VALUE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_MAXTIMEMS_TESTS)) +def test_killCursors_maxtimems(collection, test): + """Test killCursors maxTimeMS field.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_options.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_options.py new file mode 100644 index 000000000..8796c9a6a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_options.py @@ -0,0 +1,167 @@ +"""Tests for killCursors optional fields and command options.""" + +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.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Null and Missing Behavior (Success Cases)]: null values for +# optional fields (comment, maxTimeMS) and null elements in the cursors +# array are silently accepted without error. +KILLCURSORS_NULL_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_comment_omitted", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should accept omitted comment field without error", + ), + CommandTestCase( + "null_max_time_ms", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "maxTimeMS": None, + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should accept null maxTimeMS without error", + ), +] + +# Property [Unrecognized Fields]: any unrecognized field in the command +# is rejected. +KILLCURSORS_UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unrecognized_field", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "bogusField": 123, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killCursors should reject an unrecognized field", + ), +] + +# Property [writeConcern Rejection]: the command does not support +# writeConcern; a document value is rejected while null is accepted. +KILLCURSORS_WRITECONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_rejected", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "writeConcern": {}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killCursors should reject writeConcern", + ), + CommandTestCase( + "writeconcern_null", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "writeConcern": None, + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should accept null writeConcern", + ), +] + +# Property [writeConcern Type Rejection]: all non-document, non-null BSON +# types for the writeConcern field are rejected. +KILLCURSORS_WRITECONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"writeconcern_type_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "writeConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killCursors should reject {tid} writeConcern", + ) + for tid, val in [ + ("string", "majority"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", [1, 2]), + ("binary", Binary(b"data")), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +KILLCURSORS_OPTIONS_TESTS: list[CommandTestCase] = ( + KILLCURSORS_NULL_SUCCESS_TESTS + + KILLCURSORS_UNRECOGNIZED_FIELD_TESTS + + KILLCURSORS_WRITECONCERN_TESTS + + KILLCURSORS_WRITECONCERN_TYPE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_OPTIONS_TESTS)) +def test_killCursors_options(collection, test): + """Test killCursors optional fields and command options.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_readconcern.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_readconcern.py new file mode 100644 index 000000000..f570fb261 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_readconcern.py @@ -0,0 +1,371 @@ +"""Tests for killCursors readConcern field.""" + +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.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + ILLEGAL_OPERATION_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [readConcern Acceptance]: readConcern with level "local", null, +# empty document, or null level is accepted without error. +KILLCURSORS_READCONCERN_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_local", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": "local"}, + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should accept readConcern with level local", + ), + CommandTestCase( + "readconcern_null", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": None, + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should accept null readConcern", + ), + CommandTestCase( + "readconcern_empty_doc", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {}, + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should accept empty readConcern document", + ), + CommandTestCase( + "readconcern_level_null", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": None}, + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should accept readConcern with null level", + ), +] + +# Property [readConcern Level Rejection]: readConcern with levels other +# than "local" is rejected. +KILLCURSORS_READCONCERN_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_majority", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": "majority"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killCursors should reject readConcern level majority", + ), + CommandTestCase( + "readconcern_available", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": "available"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killCursors should reject readConcern level available", + ), + CommandTestCase( + "readconcern_linearizable", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": "linearizable"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killCursors should reject readConcern level linearizable", + ), + CommandTestCase( + "readconcern_snapshot", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": "snapshot"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="killCursors should reject readConcern level snapshot", + ), + CommandTestCase( + "readconcern_level_invalid_name", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="killCursors should reject unrecognized readConcern level name", + ), + CommandTestCase( + "readconcern_level_wrong_case", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": "Local"}, + }, + error_code=BAD_VALUE_ERROR, + msg="killCursors should reject wrong-case readConcern level", + ), +] + +# Property [readConcern Type Rejection]: all non-document, non-null BSON +# types for the readConcern field are rejected. +KILLCURSORS_READCONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"readconcern_type_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killCursors should reject {tid} readConcern", + ) + for tid, val in [ + ("string", "local"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", [1, 2]), + ("binary", Binary(b"data")), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Subfield Rejection]: invalid subfields, unknown +# fields, and wrong types within the readConcern document are rejected. +KILLCURSORS_READCONCERN_SUBFIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_unknown_subfield", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"bogusField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killCursors should reject unknown subfield in readConcern", + ), + CommandTestCase( + "readconcern_unknown_subfield_case_sensitive", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"Level": "local"}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="killCursors should reject case-mismatched field names in readConcern", + ), + CommandTestCase( + "readconcern_level_empty_string", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": ""}, + }, + error_code=BAD_VALUE_ERROR, + msg="killCursors should reject empty string readConcern level", + ), + CommandTestCase( + "readconcern_afterclustertime_valid_timestamp", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"afterClusterTime": Timestamp(1, 1)}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="killCursors should reject afterClusterTime in readConcern", + ), + CommandTestCase( + "readconcern_provenance_invalid", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"provenance": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="killCursors should reject invalid provenance value", + ), +] + +# Property [readConcern Level Type Rejection]: all non-string, non-null +# types for the readConcern level field are rejected. +KILLCURSORS_READCONCERN_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"readconcern_level_type_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"level": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killCursors should reject {tid} type for readConcern level", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", ["local"]), + ("document", {"a": 1}), + ("binary", Binary(b"local")), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("regex", Regex("local")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern afterClusterTime Type Rejection]: all +# non-Timestamp types for afterClusterTime are rejected. +KILLCURSORS_READCONCERN_AFTERCLUSTERTIME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"readconcern_afterclustertime_type_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"afterClusterTime": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"killCursors should reject {tid} type for afterClusterTime", + ) + for tid, val in [ + ("null", None), + ("int32", 42), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("bool", True), + ("string", "invalid"), + ("array", [1, 2]), + ("document", {"a": 1}), + ("binary", Binary(b"data")), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Provenance Acceptance]: valid provenance values +# and null are accepted. +KILLCURSORS_READCONCERN_PROVENANCE_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"readconcern_provenance_{tid}", + command=lambda ctx, v=val: { + "killCursors": ctx.collection, + "cursors": [Int64(1)], + "readConcern": {"provenance": v}, + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(1)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg=f"killCursors should accept provenance {tid!r}", + ) + for tid, val in [ + ("client_supplied", "clientSupplied"), + ("implicit_default", "implicitDefault"), + ("custom_default", "customDefault"), + ("get_last_error_defaults", "getLastErrorDefaults"), + ("null", None), + ] +] + +KILLCURSORS_READCONCERN_TESTS: list[CommandTestCase] = ( + KILLCURSORS_READCONCERN_ACCEPTANCE_TESTS + + KILLCURSORS_READCONCERN_LEVEL_ERROR_TESTS + + KILLCURSORS_READCONCERN_TYPE_ERROR_TESTS + + KILLCURSORS_READCONCERN_SUBFIELD_ERROR_TESTS + + KILLCURSORS_READCONCERN_LEVEL_TYPE_ERROR_TESTS + + KILLCURSORS_READCONCERN_AFTERCLUSTERTIME_TYPE_ERROR_TESTS + + KILLCURSORS_READCONCERN_PROVENANCE_ACCEPTANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_READCONCERN_TESTS)) +def test_killCursors_readconcern(collection, test): + """Test killCursors readConcern field.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_response.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_response.py new file mode 100644 index 000000000..34f8f4565 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_response.py @@ -0,0 +1,186 @@ +"""Tests for killCursors response structure and ordering.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.core.cursors.commands.killCursors.utils.cursor_test_case import ( # noqa: E501 + 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 + +# Property [Non-Existent Cursor IDs]: a cursor ID that does not exist on +# the server is reported in cursorsNotFound and the command succeeds with +# ok 1.0. +KILLCURSORS_NONEXISTENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "nonexistent_arbitrary_id", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(999_999)], + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(999_999)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should report non-existent cursor ID in cursorsNotFound", + ), +] + +# Property [Response Order - Not Found]: input order is preserved in +# cursorsNotFound for non-existent cursor IDs. +KILLCURSORS_ORDER_NOT_FOUND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "response_order_not_found", + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [Int64(100), Int64(200), Int64(300), Int64(50), Int64(999)], + }, + expected={ + "cursorsKilled": [], + "cursorsNotFound": [Int64(100), Int64(200), Int64(300), Int64(50), Int64(999)], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should preserve input order in cursorsNotFound", + ), +] + +KILLCURSORS_RESPONSE_CMD_TESTS: list[CommandTestCase] = ( + KILLCURSORS_NONEXISTENT_TESTS + KILLCURSORS_ORDER_NOT_FOUND_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_RESPONSE_CMD_TESTS)) +def test_killCursors_response_cmd(collection, test): + """Test killCursors response structure with non-existent cursors.""" + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +# Property [Duplicate Cursor IDs]: when the same valid cursor ID appears +# multiple times, the first occurrence is killed and subsequent occurrences +# are reported in cursorsNotFound. +KILLCURSORS_DUPLICATE_IDS_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "duplicate_ids_two", + docs=[{"_id": i} for i in range(10)], + cursor_count=1, + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [ctx.cursors[0], ctx.cursors[0]], + }, + expected=lambda ctx: { + "cursorsKilled": [ctx.cursors[0]], + "cursorsNotFound": [ctx.cursors[0]], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill first occurrence and report duplicates in cursorsNotFound", + ), + CursorCommandTestCase( + "duplicate_ids_scaled", + docs=[{"_id": i} for i in range(10)], + cursor_count=1, + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": [ctx.cursors[0]] * 100, + }, + expected=lambda ctx: { + "cursorsKilled": [ctx.cursors[0]], + "cursorsNotFound": [ctx.cursors[0]] * 99, + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should handle 100 duplicates: 1 killed, 99 notFound", + ), +] + +# Property [Collection Name Matching]: the killCursors field value is not +# validated against the cursor's origin. Any valid string kills the cursor +# by ID regardless of the collection name specified. +KILLCURSORS_COLLECTION_NAME_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "wrong_collection_name", + docs=[{"_id": i} for i in range(10)], + cursor_count=1, + command=lambda ctx: { + "killCursors": "nonexistent_other_collection", + "cursors": [ctx.cursors[0]], + }, + expected=lambda ctx: { + "cursorsKilled": [ctx.cursors[0]], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor regardless of collection name specified", + ), +] + +# Property [Response Structure - Order Preservation]: response order +# within cursorsKilled preserves the input order from the cursors array. +KILLCURSORS_CURSOR_ORDER_TESTS: list[CursorCommandTestCase] = [ + CursorCommandTestCase( + "response_order_preservation", + docs=[{"_id": i} for i in range(10)], + cursor_count=5, + command=lambda ctx: { + "killCursors": ctx.collection, + "cursors": list(reversed(ctx.cursors)), + }, + expected=lambda ctx: { + "cursorsKilled": list(reversed(ctx.cursors)), + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should preserve input order in cursorsKilled", + ), +] + +KILLCURSORS_RESPONSE_CURSOR_TESTS: list[CursorCommandTestCase] = ( + KILLCURSORS_DUPLICATE_IDS_TESTS + + KILLCURSORS_COLLECTION_NAME_TESTS + + KILLCURSORS_CURSOR_ORDER_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(KILLCURSORS_RESPONSE_CURSOR_TESTS)) +def test_killCursors_response_cursor(database_client, collection, test): + """Test killCursors response structure with active cursors.""" + collection = test.prepare(database_client, collection) + cursors = open_find_cursors(collection, test.cursor_count) + ctx = CursorCommandContext.from_collection(collection, cursors=cursors) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/__init__.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/cursor_test_case.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/cursor_test_case.py new file mode 100644 index 000000000..6d50fa14e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/cursor_test_case.py @@ -0,0 +1,54 @@ +"""Test case and helpers for killCursors tests requiring active cursors.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.executor import execute_command + + +@dataclass(frozen=True) +class CursorCommandContext(CommandContext): + """CommandContext extended with cursor IDs opened during setup.""" + + cursors: tuple[Any, ...] = () + + @classmethod + def from_collection( + cls, collection: Collection, *, cursors: tuple[Any, ...] = () + ) -> CursorCommandContext: + """Create context with optional cursor IDs.""" + base = CommandContext.from_collection(collection) + return cls( + collection=base.collection, + database=base.database, + namespace=base.namespace, + uuids=base.uuids, + cursors=cursors, + ) + + +def open_find_cursors(collection: Collection, count: int) -> tuple[Any, ...]: + """Open count find cursors with batchSize 1 and return their IDs.""" + ids = [] + for _ in range(count): + res = execute_command(collection, {"find": collection.name, "batchSize": 1}) + ids.append(res["cursor"]["id"]) + return tuple(ids) + + +@dataclass(frozen=True) +class CursorCommandTestCase(CommandTestCase): + """CommandTestCase that opens N find cursors before executing. + + The cursor IDs are available as ctx.cursors in command/expected lambdas. + """ + + cursor_count: int = 0 diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index fb18c9062..d7d280a73 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -12,6 +12,7 @@ ILLEGAL_OPERATION_ERROR = 20 NAMESPACE_NOT_FOUND_ERROR = 26 INDEX_NOT_FOUND_ERROR = 27 +CURSOR_NOT_FOUND_ERROR = 43 NAMESPACE_EXISTS_ERROR = 48 COMMAND_NOT_FOUND_ERROR = 59 CANNOT_CREATE_INDEX_ERROR = 67 diff --git a/documentdb_tests/framework/executor.py b/documentdb_tests/framework/executor.py index eb07049bb..de92b0a14 100644 --- a/documentdb_tests/framework/executor.py +++ b/documentdb_tests/framework/executor.py @@ -3,16 +3,14 @@ """ from datetime import timezone -from typing import Any, Dict, Union +from typing import Any, Dict from bson.codec_options import CodecOptions TZ_AWARE_CODEC = CodecOptions(tz_aware=True, tzinfo=timezone.utc) -def execute_command( - collection, command: Dict, codec_options=TZ_AWARE_CODEC -) -> Union[Any, Exception]: +def execute_command(collection, command: Dict, codec_options=TZ_AWARE_CODEC) -> Any: """ Execute a DocumentDB command and return result or exception. @@ -33,7 +31,7 @@ def execute_command( return e -def execute_admin_command(collection, command: Dict) -> Union[Any, Exception]: +def execute_admin_command(collection, command: Dict) -> Any: """ Execute a DocumentDB command on admin database and return result or exception. From 1498cb0f640ef070dd4d4b042f0b69162cdb2553 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Fri, 29 May 2026 16:44:47 -0700 Subject: [PATCH 2/3] Move cursor utils to shared commands/utils location Relocate the cursor test helpers from killCursors/utils to the shared commands/utils location and update the killCursors imports, so this branch aligns with the getMore branch and merges cleanly. Signed-off-by: Daniel Frankcom --- .../killCursors/test_killCursors_lifecycle.py | 2 +- .../killCursors/test_killCursors_response.py | 2 +- .../{killCursors => }/utils/__init__.py | 0 .../utils/cursor_test_case.py | 21 +++++++++++++++---- 4 files changed, 19 insertions(+), 6 deletions(-) rename documentdb_tests/compatibility/tests/core/cursors/commands/{killCursors => }/utils/__init__.py (100%) rename documentdb_tests/compatibility/tests/core/cursors/commands/{killCursors => }/utils/cursor_test_case.py (65%) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py index fadded11b..f5e566442 100644 --- a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py @@ -5,7 +5,7 @@ import pytest from bson import Int64 -from documentdb_tests.compatibility.tests.core.cursors.commands.killCursors.utils.cursor_test_case import ( # noqa: E501 +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( CursorCommandContext, CursorCommandTestCase, open_find_cursors, diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_response.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_response.py index 34f8f4565..f7bae9ed3 100644 --- a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_response.py +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_response.py @@ -9,7 +9,7 @@ CommandContext, CommandTestCase, ) -from documentdb_tests.compatibility.tests.core.cursors.commands.killCursors.utils.cursor_test_case import ( # noqa: E501 +from documentdb_tests.compatibility.tests.core.cursors.commands.utils.cursor_test_case import ( CursorCommandContext, CursorCommandTestCase, open_find_cursors, diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/__init__.py b/documentdb_tests/compatibility/tests/core/cursors/commands/utils/__init__.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/__init__.py rename to documentdb_tests/compatibility/tests/core/cursors/commands/utils/__init__.py diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/cursor_test_case.py b/documentdb_tests/compatibility/tests/core/cursors/commands/utils/cursor_test_case.py similarity index 65% rename from documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/cursor_test_case.py rename to documentdb_tests/compatibility/tests/core/cursors/commands/utils/cursor_test_case.py index 6d50fa14e..0f21dd7be 100644 --- a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/utils/cursor_test_case.py +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/utils/cursor_test_case.py @@ -1,4 +1,4 @@ -"""Test case and helpers for killCursors tests requiring active cursors.""" +"""Test case and helpers for cursor command tests requiring active cursors.""" from __future__ import annotations @@ -35,15 +35,27 @@ def from_collection( ) -def open_find_cursors(collection: Collection, count: int) -> tuple[Any, ...]: - """Open count find cursors with batchSize 1 and return their IDs.""" +def open_find_cursors( + collection: Collection, count: int, *, batch_size: int = 1 +) -> tuple[Any, ...]: + """Open count find cursors with the given batchSize and return their IDs.""" ids = [] for _ in range(count): - res = execute_command(collection, {"find": collection.name, "batchSize": 1}) + res = execute_command(collection, {"find": collection.name, "batchSize": batch_size}) ids.append(res["cursor"]["id"]) return tuple(ids) +def open_cursor(collection: Collection, find_options: dict[str, Any]) -> Any: + """Open a cursor on ``collection`` with the given find options and return its ID. + + ``find_options`` is merged into the find command (e.g. + ``{"tailable": True, "awaitData": True, "batchSize": 10}``). + """ + res = execute_command(collection, {"find": collection.name, **find_options}) + return res["cursor"]["id"] + + @dataclass(frozen=True) class CursorCommandTestCase(CommandTestCase): """CommandTestCase that opens N find cursors before executing. @@ -52,3 +64,4 @@ class CursorCommandTestCase(CommandTestCase): """ cursor_count: int = 0 + find_batch_size: int = 1 From 9cfe217f6f4bd294e15adb9e45ed2d283ced12c1 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Fri, 29 May 2026 16:57:26 -0700 Subject: [PATCH 3/3] Add view/timeseries cursor tests Signed-off-by: Daniel Frankcom --- .../killCursors/test_killCursors_lifecycle.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py index f5e566442..3b3d50099 100644 --- a/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py +++ b/documentdb_tests/compatibility/tests/core/cursors/commands/killCursors/test_killCursors_lifecycle.py @@ -2,6 +2,8 @@ from __future__ import annotations +import datetime + import pytest from bson import Int64 @@ -158,6 +160,66 @@ def test_killCursors_list_indexes_cursor(collection): ) +def test_killCursors_view_cursor(database_client, collection): + """Test killCursors kills a cursor opened against a view.""" + collection.insert_many([{"_id": i} for i in range(10)]) + view_name = collection.name + "_view" + execute_command( + collection, + {"create": view_name, "viewOn": collection.name, "pipeline": []}, + ) + view = database_client[view_name] + res = execute_command(view, {"find": view_name, "batchSize": 1}) + cursor_id = res["cursor"]["id"] + result = execute_command( + view, + {"killCursors": view_name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor from a view", + raw_res=True, + ) + + +def test_killCursors_timeseries_cursor(database_client, collection): + """Test killCursors kills a cursor opened against a time-series collection.""" + ts_name = collection.name + "_ts" + execute_command( + collection, + {"create": ts_name, "timeseries": {"timeField": "ts"}}, + ) + ts = database_client[ts_name] + ts.insert_many( + [{"ts": datetime.datetime(2024, 1, 1) + datetime.timedelta(minutes=i)} for i in range(20)] + ) + res = execute_command(ts, {"find": ts_name, "batchSize": 1}) + cursor_id = res["cursor"]["id"] + result = execute_command( + ts, + {"killCursors": ts_name, "cursors": [cursor_id]}, + ) + assertResult( + result, + expected={ + "cursorsKilled": [cursor_id], + "cursorsNotFound": [], + "cursorsAlive": [], + "cursorsUnknown": [], + "ok": 1.0, + }, + msg="killCursors should kill cursor from a time-series collection", + raw_res=True, + ) + + def test_killCursors_tailable_cursor(database_client): """Test killCursors kills a tailable cursor.""" db = database_client