diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_bson_type_validation.py new file mode 100644 index 00000000..b3322703 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_bson_type_validation.py @@ -0,0 +1,83 @@ +"""Tests for TTL index expireAfterSeconds BSON type validation. + +Verifies that createIndexes rejects invalid BSON types for expireAfterSeconds +and accepts valid numeric BSON types. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +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 CANNOT_CREATE_INDEX_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.index + +TTL_BSON_PARAMS = [ + BsonTypeTestCase( + id="expireAfterSeconds", + msg="expireAfterSeconds should reject non-numeric types", + keyword="expireAfterSeconds", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=CANNOT_CREATE_INDEX_ERROR, + valid_inputs={ + BsonType.DOUBLE: 3600.0, + BsonType.INT: 3600, + BsonType.LONG: Int64(3600), + BsonType.DECIMAL: Decimal128("3600"), + }, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(TTL_BSON_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_ttl_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test expireAfterSeconds rejects invalid BSON types.""" + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + { + "key": {"dateField": 1}, + "name": "ttl_bson_test", + "expireAfterSeconds": sample_value, + } + ], + }, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(TTL_BSON_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_ttl_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test expireAfterSeconds accepts valid numeric BSON types.""" + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + { + "key": {"dateField": 1}, + "name": "ttl_bson_test", + "expireAfterSeconds": sample_value, + } + ], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg=f"expireAfterSeconds should accept {bson_type.value}", + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_create.py b/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_create.py new file mode 100644 index 00000000..121f095f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_create.py @@ -0,0 +1,122 @@ +"""Tests for valid TTL index creation via createIndexes. + +Validates allowed key patterns, special index types, and valid +expireAfterSeconds boundary values. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + INT32_MAX, + INT32_MAX_MINUS_1, + INT32_ZERO, +) + +pytestmark = pytest.mark.index + +TTL_CREATE_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="descending_field", + indexes=({"key": {"dateField": -1}, "name": "ttl_desc", "expireAfterSeconds": 3600},), + msg="Should allow TTL on single descending field", + ), + IndexTestCase( + id="hashed_field", + indexes=({"key": {"dateField": "hashed"}, "name": "ttl_hash", "expireAfterSeconds": 3600},), + msg="Should allow TTL on hashed index", + ), + IndexTestCase( + id="text_field", + indexes=({"key": {"dateField": "text"}, "name": "ttl_text", "expireAfterSeconds": 3600},), + msg="Should allow TTL on text index", + ), + IndexTestCase( + id="2dsphere_field", + indexes=( + {"key": {"dateField": "2dsphere"}, "name": "ttl_2ds", "expireAfterSeconds": 3600}, + ), + msg="Should allow TTL on 2dsphere index", + ), + IndexTestCase( + id="2d_field", + indexes=({"key": {"dateField": "2d"}, "name": "ttl_2d", "expireAfterSeconds": 3600},), + msg="Should allow TTL on 2d index", + ), + IndexTestCase( + id="value_zero", + indexes=({"key": {"d": 1}, "name": "ttl_0", "expireAfterSeconds": INT32_ZERO},), + msg="Should accept expireAfterSeconds 0", + ), + IndexTestCase( + id="value_just_below_max", + indexes=( + {"key": {"d": 1}, "name": "ttl_below_max", "expireAfterSeconds": INT32_MAX_MINUS_1}, + ), + msg="Should accept expireAfterSeconds just below INT32_MAX", + ), + IndexTestCase( + id="value_int32_max", + indexes=({"key": {"d": 1}, "name": "ttl_max", "expireAfterSeconds": INT32_MAX},), + msg="Should accept expireAfterSeconds at INT32_MAX", + ), + IndexTestCase( + id="value_int32_max_as_int64", + indexes=({"key": {"d": 1}, "name": "ttl_i64", "expireAfterSeconds": Int64(INT32_MAX)},), + msg="Should accept INT32_MAX as Int64", + ), + IndexTestCase( + id="value_int32_max_as_double", + indexes=({"key": {"d": 1}, "name": "ttl_dbl", "expireAfterSeconds": float(INT32_MAX)},), + msg="Should accept INT32_MAX as double", + ), + IndexTestCase( + id="value_int32_max_as_decimal128", + indexes=( + { + "key": {"d": 1}, + "name": "ttl_dec", + "expireAfterSeconds": Decimal128(str(INT32_MAX)), + }, + ), + msg="Should accept INT32_MAX as Decimal128", + ), + IndexTestCase( + id="value_negative_zero_double", + indexes=( + {"key": {"d": 1}, "name": "ttl_nz_d", "expireAfterSeconds": DOUBLE_NEGATIVE_ZERO}, + ), + msg="Should accept negative zero double (stored as 0)", + ), + IndexTestCase( + id="value_negative_zero_decimal128", + indexes=( + { + "key": {"d": 1}, + "name": "ttl_nz_dec", + "expireAfterSeconds": DECIMAL128_NEGATIVE_ZERO, + }, + ), + msg="Should accept negative zero Decimal128 (stored as 0)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TTL_CREATE_TESTS)) +def test_ttl_create_success(collection, test): + """Test that createIndexes succeeds for valid TTL index specs.""" + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + assertSuccessPartial( + result, {"ok": 1.0, "numIndexesBefore": 1, "numIndexesAfter": 2}, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_errors.py b/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_errors.py new file mode 100644 index 00000000..53058efe --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_errors.py @@ -0,0 +1,200 @@ +"""Tests for invalid TTL index creation via createIndexes. + +Validates that createIndexes rejects disallowed key patterns, invalid +expireAfterSeconds values, and conflicting index options. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + CANNOT_CREATE_INDEX_ERROR, + INDEX_OPTIONS_CONFLICT_ERROR, + INVALID_INDEX_SPEC_OPTION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + +CREATE_INDEX_ERROR_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="key_compound", + indexes=( + { + "key": {"dateField": 1, "other": 1}, + "name": "ttl_compound", + "expireAfterSeconds": 3600, + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject TTL on compound index", + ), + IndexTestCase( + id="key_id_field", + indexes=({"key": {"_id": 1}, "name": "ttl_id", "expireAfterSeconds": 3600},), + error_code=INVALID_INDEX_SPEC_OPTION_ERROR, + msg="Should reject TTL on _id field", + ), + IndexTestCase( + id="key_wildcard", + indexes=({"key": {"$**": 1}, "name": "ttl_wild", "expireAfterSeconds": 3600},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject TTL on wildcard index", + ), + IndexTestCase( + id="value_negative_int", + indexes=({"key": {"d": 1}, "name": "ttl_neg", "expireAfterSeconds": -1},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject negative int expireAfterSeconds", + ), + IndexTestCase( + id="value_negative_int64", + indexes=({"key": {"d": 1}, "name": "ttl_neg_l", "expireAfterSeconds": Int64(-1)},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject negative Int64 expireAfterSeconds", + ), + IndexTestCase( + id="value_negative_double", + indexes=({"key": {"d": 1}, "name": "ttl_neg_d", "expireAfterSeconds": -1.0},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject negative double expireAfterSeconds", + ), + IndexTestCase( + id="value_negative_decimal128", + indexes=({"key": {"d": 1}, "name": "ttl_neg_dec", "expireAfterSeconds": Decimal128("-1")},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject negative Decimal128 expireAfterSeconds", + ), + IndexTestCase( + id="value_int32_min", + indexes=({"key": {"d": 1}, "name": "ttl_i32min", "expireAfterSeconds": -2147483648},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject INT32_MIN expireAfterSeconds", + ), + IndexTestCase( + id="value_nan_double", + indexes=({"key": {"d": 1}, "name": "ttl_nan", "expireAfterSeconds": float("nan")},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject NaN expireAfterSeconds", + ), + IndexTestCase( + id="value_nan_decimal128", + indexes=( + {"key": {"d": 1}, "name": "ttl_nan_dec", "expireAfterSeconds": Decimal128("NaN")}, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject Decimal128 NaN expireAfterSeconds", + ), + IndexTestCase( + id="value_infinity", + indexes=({"key": {"d": 1}, "name": "ttl_inf", "expireAfterSeconds": float("inf")},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject Infinity expireAfterSeconds", + ), + IndexTestCase( + id="value_negative_infinity", + indexes=({"key": {"d": 1}, "name": "ttl_ninf", "expireAfterSeconds": float("-inf")},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject -Infinity expireAfterSeconds", + ), + IndexTestCase( + id="value_decimal128_infinity", + indexes=( + {"key": {"d": 1}, "name": "ttl_dec_inf", "expireAfterSeconds": Decimal128("Infinity")}, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject Decimal128 Infinity expireAfterSeconds", + ), + IndexTestCase( + id="value_decimal128_negative_infinity", + indexes=( + { + "key": {"d": 1}, + "name": "ttl_dec_ninf", + "expireAfterSeconds": Decimal128("-Infinity"), + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject Decimal128 -Infinity expireAfterSeconds", + ), + IndexTestCase( + id="value_above_int32_max", + indexes=({"key": {"d": 1}, "name": "ttl_over", "expireAfterSeconds": 2147483648},), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject value above INT32_MAX", + ), + IndexTestCase( + id="value_int64_above_int32_max", + indexes=( + {"key": {"d": 1}, "name": "ttl_i64_over", "expireAfterSeconds": Int64(2147483648)}, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject Int64 above INT32_MAX", + ), + IndexTestCase( + id="value_int64_max", + indexes=( + { + "key": {"d": 1}, + "name": "ttl_i64max", + "expireAfterSeconds": Int64(9223372036854775807), + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject INT64_MAX expireAfterSeconds", + ), + IndexTestCase( + id="value_very_large_decimal128", + indexes=( + { + "key": {"d": 1}, + "name": "ttl_huge", + "expireAfterSeconds": Decimal128("10002147483648"), + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject very large Decimal128 expireAfterSeconds", + ), + IndexTestCase( + id="conflict_non_ttl_exists", + setup_indexes=[{"key": {"dateField": 1}, "name": "dateField_1"}], + indexes=({"key": {"dateField": 1}, "name": "dateField_1_ttl", "expireAfterSeconds": 3600},), + error_code=INDEX_OPTIONS_CONFLICT_ERROR, + msg="TTL on key with existing non-TTL index should fail", + ), + IndexTestCase( + id="conflict_different_expire", + setup_indexes=[{"key": {"dateField": 1}, "name": "ttl_3600", "expireAfterSeconds": 3600}], + indexes=({"key": {"dateField": 1}, "name": "ttl_7200", "expireAfterSeconds": 7200},), + error_code=INDEX_OPTIONS_CONFLICT_ERROR, + msg="Different expireAfterSeconds on same key should fail", + ), + IndexTestCase( + id="conflict_decimal128_on_non_ttl", + setup_indexes=[{"key": {"dateField": 1}, "name": "dateField_1"}], + indexes=( + {"key": {"dateField": 1}, "name": "ttl_dec", "expireAfterSeconds": Decimal128("3600")}, + ), + error_code=INDEX_OPTIONS_CONFLICT_ERROR, + msg="TTL Decimal128 on key with existing non-TTL should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CREATE_INDEX_ERROR_TESTS)) +def test_ttl_create_index_error(collection, test): + """Test that createIndexes rejects invalid TTL index specs.""" + if test.setup_indexes: + execute_command( + collection, {"createIndexes": collection.name, "indexes": test.setup_indexes} + ) + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_operations.py b/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_operations.py new file mode 100644 index 00000000..9ff1205f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/ttl/test_ttl_operations.py @@ -0,0 +1,131 @@ +"""Tests for TTL index runtime behavior. + +Validates multiple TTL indexes on the same collection and +expireAfterSeconds storage consistency across numeric types. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + + +def test_multiple_ttl_indexes_different_fields(collection): + """Test creating two TTL indexes on different fields succeeds.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"dateField1": 1}, "name": "ttl_1", "expireAfterSeconds": 3600}], + }, + ) + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"dateField2": 1}, "name": "ttl_2", "expireAfterSeconds": 7200}], + }, + ) + assertSuccessPartial(result, {"ok": 1.0, "numIndexesBefore": 2, "numIndexesAfter": 3}) + + +def test_multiple_ttl_indexes_listed(collection): + """Test listIndexes shows both TTL indexes with correct expireAfterSeconds.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"dateField1": 1}, "name": "ttl_1", "expireAfterSeconds": 3600}, + {"key": {"dateField2": 1}, "name": "ttl_2", "expireAfterSeconds": 7200}, + ], + }, + ) + result = execute_command(collection, {"listIndexes": collection.name}) + assertSuccess( + result, + [ + {"name": "ttl_1", "expireAfterSeconds": 3600}, + {"name": "ttl_2", "expireAfterSeconds": 7200}, + ], + transform=lambda docs: [ + {"name": d["name"], "expireAfterSeconds": d["expireAfterSeconds"]} + for d in docs + if "expireAfterSeconds" in d + ], + ) + + +STORAGE_CASES: list[IndexTestCase] = [ + IndexTestCase( + id="stored_as_int32", + indexes=({"key": {"dateField": 1}, "name": "ttl_i32", "expireAfterSeconds": 3600},), + expected=[{"name": "ttl_i32", "expireAfterSeconds": 3600}], + msg="int32 expireAfterSeconds should store correctly", + ), + IndexTestCase( + id="stored_as_int64", + indexes=({"key": {"dateField": 1}, "name": "ttl_i64", "expireAfterSeconds": Int64(3600)},), + expected=[{"name": "ttl_i64", "expireAfterSeconds": 3600}], + msg="Int64 expireAfterSeconds should store same value", + ), + IndexTestCase( + id="stored_as_double", + indexes=({"key": {"dateField": 1}, "name": "ttl_dbl", "expireAfterSeconds": 3600.0},), + expected=[{"name": "ttl_dbl", "expireAfterSeconds": 3600}], + msg="double expireAfterSeconds should store same value", + ), + IndexTestCase( + id="stored_as_decimal128", + indexes=( + {"key": {"dateField": 1}, "name": "ttl_dec", "expireAfterSeconds": Decimal128("3600")}, + ), + expected=[{"name": "ttl_dec", "expireAfterSeconds": 3600}], + msg="Decimal128 expireAfterSeconds should store same value", + ), + IndexTestCase( + id="stored_fractional_double_truncated", + indexes=({"key": {"dateField": 1}, "name": "ttl_frac_d", "expireAfterSeconds": 3600.5},), + expected=[{"name": "ttl_frac_d", "expireAfterSeconds": 3600}], + msg="Fractional double expireAfterSeconds should be truncated to integer", + ), + IndexTestCase( + id="stored_fractional_decimal128_truncated", + indexes=( + { + "key": {"dateField": 1}, + "name": "ttl_frac_dec", + "expireAfterSeconds": Decimal128("3600.5"), + }, + ), + expected=[{"name": "ttl_frac_dec", "expireAfterSeconds": 3600}], + msg="Fractional Decimal128 expireAfterSeconds should be truncated to integer", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(STORAGE_CASES)) +def test_expire_after_seconds_storage(collection, test): + """Test expireAfterSeconds stored consistently across numeric types.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + result = execute_command(collection, {"listIndexes": collection.name}) + assertSuccess( + result, + test.expected, + msg=test.msg, + transform=lambda docs: [ + {"name": d["name"], "expireAfterSeconds": d["expireAfterSeconds"]} + for d in docs + if "expireAfterSeconds" in d + ], + )