Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""readConcern BSON type validation: field must be a document, level must be a string."""

import pytest

from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess
from documentdb_tests.framework.bson_type_validator import (
BsonType,
BsonTypeTestCase,
generate_bson_acceptance_test_cases,
generate_bson_rejection_test_cases,
)
from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR
from documentdb_tests.framework.executor import execute_command

READ_CONCERN_PARAMS = [
BsonTypeTestCase(
id="read_concern_field",
msg="readConcern field should reject non-document BSON types",
valid_types=[BsonType.OBJECT],
skip_rejection_types=[BsonType.NULL],
default_error_code=TYPE_MISMATCH_ERROR,
expected=[{"_id": 1, "x": 1}],
valid_inputs={BsonType.OBJECT: {"level": "local"}},
),
BsonTypeTestCase(
id="level_field",
msg="readConcern.level should reject non-string BSON types",
valid_types=[BsonType.STRING],
skip_rejection_types=[BsonType.NULL],
default_error_code=TYPE_MISMATCH_ERROR,
expected=[{"_id": 1, "x": 1}],
valid_inputs={BsonType.STRING: "local"},
),
]


def _build_read_concern(spec, sample_value):
"""Build the readConcern value based on which aspect is being tested."""
if spec.id == "read_concern_field":
return sample_value
# level_field: wrap the sample as the level sub-field.
return {"level": sample_value}


@pytest.mark.parametrize(
"bson_type,sample_value,spec", generate_bson_rejection_test_cases(READ_CONCERN_PARAMS)
)
def test_read_concern_bson_type_rejected(collection, bson_type, sample_value, spec):
"""Test readConcern rejects invalid BSON types for the field and level sub-field."""
collection.insert_one({"_id": 1, "x": 1})
result = execute_command(
collection,
{
"find": collection.name,
"filter": {},
"readConcern": _build_read_concern(spec, sample_value),
},
)
assertFailureCode(
result,
spec.expected_code(bson_type),
msg=f"readConcern should reject {bson_type.value} for {spec.id}",
)


@pytest.mark.parametrize(
"bson_type,sample_value,spec", generate_bson_acceptance_test_cases(READ_CONCERN_PARAMS)
)
def test_read_concern_bson_type_accepted(collection, bson_type, sample_value, spec):
"""Test readConcern accepts valid BSON types for the field and level sub-field."""
collection.insert_one({"_id": 1, "x": 1})
result = execute_command(
collection,
{
"find": collection.name,
"filter": {},
"readConcern": _build_read_concern(spec, sample_value),
},
)
assertSuccess(
result, spec.expected, msg=f"readConcern should accept {bson_type.value} for {spec.id}"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""readConcern with find: command options, empty/non-existent collections, views, and getMore."""

from typing import Any, Dict, cast

import pytest

from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase
from documentdb_tests.framework.assertions import assertProperties, assertResult
from documentdb_tests.framework.executor import execute_command
from documentdb_tests.framework.parametrize import pytest_params
from documentdb_tests.framework.property_checks import Eq
from documentdb_tests.framework.target_collection import ViewCollection

INTERACTION_TESTS: list[CommandTestCase] = [
CommandTestCase(
"find_with_options_and_read_concern",
docs=[{"_id": 1, "x": 3, "y": 9}, {"_id": 2, "x": 1, "y": 9}, {"_id": 3, "x": 2, "y": 9}],
command={
"filter": {},
"sort": {"x": 1},
"projection": {"x": 1, "_id": 1},
"limit": 2,
"readConcern": {"level": "local"},
},
expected=[{"_id": 2, "x": 1}, {"_id": 3, "x": 2}],
msg="find with readConcern should not interfere with sort/projection/limit options.",
),
CommandTestCase(
"find_on_empty_collection",
docs=[],
command={"filter": {}, "readConcern": {"level": "local"}},
expected=[],
msg="find with readConcern on empty collection should return empty.",
),
CommandTestCase(
"find_on_nonexistent_collection",
docs=None,
command={"filter": {}, "readConcern": {"level": "local"}},
expected=[],
msg="find with readConcern on non-existent collection should return empty.",
),
CommandTestCase(
"find_on_view",
target_collection=ViewCollection(options={"pipeline": [{"$match": {"x": {"$gte": 10}}}]}),
docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 5}],
command={"filter": {}, "sort": {"_id": 1}, "readConcern": {"level": "local"}},
expected=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}],
msg="find with readConcern on view should return filtered view results.",
),
CommandTestCase(
"find_first_batch_with_batch_size",
docs=[{"_id": i, "x": i} for i in range(10)],
command={
"filter": {},
"batchSize": 3,
"sort": {"_id": 1},
"readConcern": {"level": "local"},
},
expected=[{"_id": 0, "x": 0}, {"_id": 1, "x": 1}, {"_id": 2, "x": 2}],
msg="first batch from find with readConcern should contain 3 documents.",
),
]


@pytest.mark.parametrize("test", pytest_params(INTERACTION_TESTS))
def test_read_concern_command_interaction(collection, test: CommandTestCase):
"""Test readConcern works with other command options, collection states, and views."""
collection = test.prepare(collection.database, collection)
find_body = cast(Dict[str, Any], test.command)
result = execute_command(collection, {"find": collection.name, **find_body})
assertResult(result, expected=test.expected, msg=test.msg)


def test_getmore_after_find_with_read_concern_next_batch(collection):
"""Test getMore after find with readConcern returns next batch correctly."""
collection.insert_many([{"_id": i, "x": i} for i in range(10)])

initial_result = execute_command(
collection,
{
"find": collection.name,
"filter": {},
"batchSize": 3,
"sort": {"_id": 1},
"readConcern": {"level": "local"},
},
)
cursor_id = initial_result["cursor"]["id"]

getmore_result = execute_command(
collection,
{"getMore": cursor_id, "collection": collection.name, "batchSize": 3},
)
expected_next = [{"_id": 3, "x": 3}, {"_id": 4, "x": 4}, {"_id": 5, "x": 5}]
assertProperties(
getmore_result,
{"cursor.nextBatch": Eq(expected_next), "ok": Eq(1.0)},
raw_res=True,
msg="getMore after readConcern find should return next batch correctly.",
)


def test_getmore_ignores_read_concern_parameter(collection):
"""Test getMore ignores a readConcern parameter and still returns the next batch."""
collection.insert_many([{"_id": i, "x": i} for i in range(10)])

initial_result = execute_command(
collection,
{
"find": collection.name,
"filter": {},
"batchSize": 3,
"sort": {"_id": 1},
"readConcern": {"level": "local"},
},
)
cursor_id = initial_result["cursor"]["id"]

getmore_result = execute_command(
collection,
{
"getMore": cursor_id,
"collection": collection.name,
"batchSize": 3,
"readConcern": {"level": "local"},
},
)
expected_next = [{"_id": 3, "x": 3}, {"_id": 4, "x": 4}, {"_id": 5, "x": 5}]
assertProperties(
getmore_result,
{"cursor.nextBatch": Eq(expected_next), "ok": Eq(1.0)},
raw_res=True,
msg="getMore should ignore a readConcern parameter and return the next batch.",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""readConcern rejection cases with find: bad levels, unknown fields, topology, afterClusterTime."""

from typing import Any, Dict, cast

import pytest

from documentdb_tests.compatibility.tests.core.utils.command_test_case import CommandTestCase
from documentdb_tests.framework.assertions import assertResult
from documentdb_tests.framework.error_codes import (
BAD_VALUE_ERROR,
NOT_A_REPLICA_SET_ERROR,
TYPE_MISMATCH_ERROR,
UNRECOGNIZED_COMMAND_FIELD_ERROR,
)
from documentdb_tests.framework.executor import execute_command
from documentdb_tests.framework.parametrize import pytest_params

_REQUIRES_STANDALONE = (pytest.mark.requires(cluster_read_concern=False),)
_REQUIRES_REPLICA_SET = (pytest.mark.requires(cluster_read_concern=True),)

INVALID_LEVEL_STRING_TESTS: list[CommandTestCase] = [
CommandTestCase(
"find_rejects_empty_string_level",
command={"filter": {}, "readConcern": {"level": ""}},
error_code=BAD_VALUE_ERROR,
msg="find should reject empty readConcern level string.",
),
CommandTestCase(
"find_rejects_unknown_level_string",
command={"filter": {}, "readConcern": {"level": "invalid"}},
error_code=BAD_VALUE_ERROR,
msg="find should reject unrecognized readConcern level string 'invalid'.",
),
CommandTestCase(
"find_rejects_uppercase_level",
command={"filter": {}, "readConcern": {"level": "LOCAL"}},
error_code=BAD_VALUE_ERROR,
msg="find should reject uppercase readConcern level 'LOCAL'.",
),
CommandTestCase(
"find_rejects_mixed_case_level",
command={"filter": {}, "readConcern": {"level": "Majority"}},
error_code=BAD_VALUE_ERROR,
msg="find should reject mixed-case readConcern level 'Majority'.",
),
CommandTestCase(
"find_rejects_nonexistent_level",
command={"filter": {}, "readConcern": {"level": "strong"}},
error_code=BAD_VALUE_ERROR,
msg="find should reject nonexistent readConcern level 'strong'.",
),
CommandTestCase(
"find_rejects_null_byte_in_level",
command={"filter": {}, "readConcern": {"level": "local\x00extra"}},
error_code=BAD_VALUE_ERROR,
msg="find should reject null byte in readConcern level string.",
),
]

UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [
CommandTestCase(
"find_rejects_unknown_field_no_level",
command={"filter": {}, "readConcern": {"unknownField": 1}},
error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR,
msg="find should reject readConcern with unknown field and no level.",
),
CommandTestCase(
"find_rejects_extra_field_with_valid_level",
command={"filter": {}, "readConcern": {"level": "local", "unknownField": 1}},
error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR,
msg="find should reject readConcern with extra unknown field.",
),
]


AFTER_CLUSTER_TIME_TESTS: list[CommandTestCase] = [
CommandTestCase(
"find_rejects_afterClusterTime_string",
command={"filter": {}, "readConcern": {"level": "local", "afterClusterTime": "invalid"}},
error_code=TYPE_MISMATCH_ERROR,
msg="find should reject non-Timestamp afterClusterTime (string).",
marks=_REQUIRES_REPLICA_SET,
),
CommandTestCase(
"find_rejects_afterClusterTime_integer",
command={"filter": {}, "readConcern": {"level": "local", "afterClusterTime": 12345}},
error_code=TYPE_MISMATCH_ERROR,
msg="find should reject non-Timestamp afterClusterTime (integer).",
marks=_REQUIRES_REPLICA_SET,
),
CommandTestCase(
"find_rejects_afterClusterTime_null",
command={"filter": {}, "readConcern": {"level": "local", "afterClusterTime": None}},
error_code=TYPE_MISMATCH_ERROR,
msg="find should reject non-Timestamp afterClusterTime (null).",
marks=_REQUIRES_REPLICA_SET,
),
]

# 'snapshot' and 'linearizable' both require a replicated topology and are rejected on standalone.
REPLICA_SET_ONLY_LEVEL_TESTS: list[CommandTestCase] = [
CommandTestCase(
"find_rejects_snapshot_on_standalone",
docs=[{"_id": 1}],
command={"filter": {}, "readConcern": {"level": "snapshot"}},
error_code=NOT_A_REPLICA_SET_ERROR,
msg="readConcern 'snapshot' should be rejected on a standalone (not a replica set).",
marks=_REQUIRES_STANDALONE,
),
CommandTestCase(
"find_rejects_linearizable_on_standalone",
docs=[{"_id": 1}],
command={"filter": {}, "readConcern": {"level": "linearizable"}},
error_code=NOT_A_REPLICA_SET_ERROR,
msg="readConcern 'linearizable' should be rejected on a standalone (not a replica set).",
marks=_REQUIRES_STANDALONE,
),
]

ERROR_TESTS: list[CommandTestCase] = (
INVALID_LEVEL_STRING_TESTS
+ UNKNOWN_FIELD_TESTS
+ REPLICA_SET_ONLY_LEVEL_TESTS
+ AFTER_CLUSTER_TIME_TESTS
)


@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS))
def test_read_concern_rejected(collection, test: CommandTestCase):
"""Test readConcern rejection cases return the expected error code."""
collection = test.prepare(collection.database, collection)
find_body = cast(Dict[str, Any], test.command)
result = execute_command(collection, {"find": collection.name, **find_body})
assertResult(result, error_code=test.error_code, msg=test.msg)
Loading
Loading