From 88681438c28cf5e168b6a39094c521cb6920d40d Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Mon, 15 Jun 2026 09:04:58 -0700 Subject: [PATCH] Add autoCompact command tests Signed-off-by: Daniel Frankcom --- .../commands/autoCompact/__init__.py | 0 .../test_autoCompact_fstmb_bounds.py | 295 ++++++++++++++++++ .../test_autoCompact_fstmb_overflow.py | 169 ++++++++++ .../test_autoCompact_operational.py | 78 +++++ .../test_autoCompact_request_validation.py | 221 +++++++++++++ .../autoCompact/test_autoCompact_success.py | 126 ++++++++ .../autoCompact/test_smoke_autoCompact.py | 8 +- .../commands/autoCompact/utils/__init__.py | 0 .../autoCompact/utils/autoCompact_common.py | 33 ++ documentdb_tests/framework/error_codes.py | 1 + 10 files changed, 930 insertions(+), 1 deletion(-) create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py new file mode 100644 index 000000000..52372da22 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_bounds.py @@ -0,0 +1,295 @@ +"""Tests for autoCompact freeSpaceTargetMB numeric coercion and the lower bound.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INT64_UNDERFLOW, + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_HALF, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_HALF, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ONE_AND_HALF, + DOUBLE_ZERO, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT64_MIN, +) + +# Property [freeSpaceTargetMB Accepted Values]: a freeSpaceTargetMB whose +# coerced value is >= 1 is accepted across all numeric BSON types and across the +# int32/int64 boundary. +AUTOCOMPACT_FSTMB_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "fstmb_min_one", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 1}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept the minimum freeSpaceTargetMB of 1", + ), + CommandTestCase( + "fstmb_type_double", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 1.0}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a double freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_type_long", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": Int64(1)}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a long freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_type_decimal", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": Decimal128("1")}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a decimal freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_int32_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT32_MAX}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept int32 max as freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_above_int32_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": Int64(INT32_OVERFLOW)}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a long just above int32 max as freeSpaceTargetMB", + ), + CommandTestCase( + "fstmb_decimal_trailing_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_TRAILING_ZERO}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept a trailing-zero decimal freeSpaceTargetMB coercing to 1", + ), +] + +# Property [freeSpaceTargetMB Fractional Coercion]: a fractional +# freeSpaceTargetMB is coerced to an integer before validation: doubles +# truncate toward zero and decimals round half-to-even. +AUTOCOMPACT_FSTMB_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "fstmb_double_one_and_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_ONE_AND_HALF}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should truncate a double freeSpaceTargetMB toward zero to an accepted 1", + ), + CommandTestCase( + "fstmb_decimal_just_above_half_full_precision", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": DECIMAL128_JUST_ABOVE_HALF, + }, + expected={"ok": Eq(1.0)}, + msg="autoCompact should round a 34-digit just-above-half decimal up to an accepted 1", + ), + CommandTestCase( + "fstmb_decimal_one_and_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_ONE_AND_HALF}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should round a decimal half-value half-to-even up to an accepted 2", + ), +] + +# Property [freeSpaceTargetMB Value Validation - Lower Bound]: a +# freeSpaceTargetMB whose coerced integer value is below 1 produces a bad-value +# error. +AUTOCOMPACT_FSTMB_LOWER_BOUND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lower_bound_int_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 0}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject an int freeSpaceTargetMB of 0 as below the lower bound", + ), + CommandTestCase( + "lower_bound_int32_min", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT32_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject int32 min freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_int64_min", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT64_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject int64 min freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject a double 0.0 freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_negative_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_NEGATIVE_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce double -0.0 to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject a decimal 0 freeSpaceTargetMB as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_negative_zero", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_NEGATIVE_ZERO}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce decimal -0 to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_near_one", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": 0.999999}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should truncate a double just below 1 to 0 and reject it as below bound", + ), + CommandTestCase( + "lower_bound_double_negative_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_NEGATIVE_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should truncate double -0.5 to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should round decimal 0.5 half-to-even to 0 and reject it below the bound", + ), + CommandTestCase( + "lower_bound_decimal_negative_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_NEGATIVE_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should round decimal -0.5 half-to-even to 0 and reject it below the bound", + ), + CommandTestCase( + "lower_bound_double_min_subnormal", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_MIN_SUBNORMAL}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should truncate the min subnormal double to 0 and reject it below bound", + ), + CommandTestCase( + "lower_bound_decimal_min_positive", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MIN_POSITIVE}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce the smallest positive decimal to 0 and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_max_negative", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MAX_NEGATIVE}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce the smallest negative decimal to 0 and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_just_below_half", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_JUST_BELOW_HALF}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should round a 34-digit just-below-half decimal to 0 and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_int64_underflow", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": DECIMAL128_INT64_UNDERFLOW, + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate a below-int64-min decimal to int64 min and reject it " + "below bound", + ), + CommandTestCase( + "lower_bound_decimal_min", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate a far-negative decimal to int64 min and reject it below " + "bound", + ), + CommandTestCase( + "lower_bound_decimal_min_disable", + command=lambda ctx: {"autoCompact": False, "freeSpaceTargetMB": DECIMAL128_MIN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact disable should still enforce the lower bound on a far-negative decimal", + ), + CommandTestCase( + "lower_bound_double_nan", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": FLOAT_NAN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce double NaN to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_double_negative_infinity", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": FLOAT_NEGATIVE_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate double -Infinity to int64 min and reject it below bound", + ), + CommandTestCase( + "lower_bound_decimal_nan", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_NAN}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should coerce decimal NaN to 0 and reject it as below the lower bound", + ), + CommandTestCase( + "lower_bound_decimal_negative_infinity", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": DECIMAL128_NEGATIVE_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should saturate decimal -Infinity to int64 min and reject it below bound", + ), +] + +AUTOCOMPACT_FSTMB_BOUNDS_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_FSTMB_ACCEPTED_TESTS + + AUTOCOMPACT_FSTMB_FRACTIONAL_TESTS + + AUTOCOMPACT_FSTMB_LOWER_BOUND_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_FSTMB_BOUNDS_TESTS)) +def test_autoCompact_fstmb_bounds(database_client, collection, test): + """Test autoCompact freeSpaceTargetMB coercion and lower-bound enforcement.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Ensure autoCompact is idle first: a leftover config from a prior test + # would otherwise conflict. + ensure_autocompact_idle(collection) + result = execute_admin_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/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py new file mode 100644 index 000000000..b9d96d4fb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_fstmb_overflow.py @@ -0,0 +1,169 @@ +"""Tests for the autoCompact freeSpaceTargetMB MB-to-bytes overflow boundary.""" + +from __future__ import annotations + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_INT64_OVERFLOW, + DECIMAL128_MAX, + DOUBLE_FROM_INT64_MAX, + DOUBLE_MAX, + DOUBLE_MAX_SAFE_INTEGER, + FLOAT_INFINITY, + INT64_MAX, +) + +# freeSpaceTargetMB is converted to bytes as a signed int64 (value * 2^20), so +# the largest value whose byte product still fits is INT64_MAX // 2^20 and the +# smallest value that overflows to a negative signed int64 is one past it. +_MB_IN_BYTES = 1 << 20 +_FSTMB_BYTE_OVERFLOW = INT64_MAX // _MB_IN_BYTES + 1 + +# Property [freeSpaceTargetMB Enable/Disable Path Asymmetry]: the MB-to-bytes +# overflow check runs only on the enable path, so a freeSpaceTargetMB whose byte +# product overflows signed int64 is accepted when disabling. +AUTOCOMPACT_FSTMB_PATH_ASYMMETRY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "path_disable_overflow", + command=lambda ctx: { + "autoCompact": False, + "freeSpaceTargetMB": Int64(_FSTMB_BYTE_OVERFLOW), + }, + expected={"ok": Eq(1.0)}, + msg="autoCompact disable should accept a freeSpaceTargetMB whose byte product overflows", + ), +] + +# Property [freeSpaceTargetMB Value Validation - Byte-Level Minimum]: a +# freeSpaceTargetMB that passes the lower bound but whose byte conversion wraps +# back to zero, below the byte minimum, is still rejected. Unlike the overflow +# cases the wrapped product is non-negative, so an implementation guarding only +# against a negative wrap would wrongly accept it; this region needs separate +# coverage. +AUTOCOMPACT_FSTMB_BYTE_MINIMUM_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "byte_minimum_double_max_safe_integer", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": float(DOUBLE_MAX_SAFE_INTEGER), + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject a freeSpaceTargetMB whose byte product wraps to zero", + ), +] + +# Property [freeSpaceTargetMB Overflow Boundary]: the largest freeSpaceTargetMB +# whose byte product still fits in signed int64 is accepted on the enable path. +AUTOCOMPACT_FSTMB_OVERFLOW_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "overflow_accepted_boundary", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": Int64(_FSTMB_BYTE_OVERFLOW - 1), + }, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept the largest freeSpaceTargetMB whose byte product stays " + "positive", + ), +] + +# Property [freeSpaceTargetMB Value Validation - Overflow]: on the enable path, +# a freeSpaceTargetMB whose MB-to-bytes product wraps to a negative signed int64 +# produces a bad-value error. +AUTOCOMPACT_FSTMB_OVERFLOW_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "overflow_boundary_plus_one", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": Int64(_FSTMB_BYTE_OVERFLOW), + }, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the smallest freeSpaceTargetMB whose byte product wraps " + "negative", + ), + CommandTestCase( + "overflow_int64_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": INT64_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject int64 max freeSpaceTargetMB whose byte product overflows", + ), + CommandTestCase( + "overflow_double_from_int64_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_FROM_INT64_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the int64-max-as-double freeSpaceTargetMB in the overflow " + "region", + ), + CommandTestCase( + "overflow_double_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DOUBLE_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the max double freeSpaceTargetMB in the overflow region", + ), + CommandTestCase( + "overflow_double_infinity", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": FLOAT_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject double +Infinity freeSpaceTargetMB in the overflow region", + ), + CommandTestCase( + "overflow_decimal_max", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_MAX}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject the max decimal freeSpaceTargetMB in the overflow region", + ), + CommandTestCase( + "overflow_decimal_int64_overflow", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_INT64_OVERFLOW}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should absorb an above-int64-max decimal freeSpaceTargetMB into the " + "overflow path", + ), + CommandTestCase( + "overflow_decimal_infinity", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": DECIMAL128_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="autoCompact should reject decimal Infinity freeSpaceTargetMB in the overflow region", + ), +] + +AUTOCOMPACT_FSTMB_OVERFLOW_BOUNDARY_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_FSTMB_PATH_ASYMMETRY_TESTS + + AUTOCOMPACT_FSTMB_BYTE_MINIMUM_TESTS + + AUTOCOMPACT_FSTMB_OVERFLOW_ACCEPTED_TESTS + + AUTOCOMPACT_FSTMB_OVERFLOW_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_FSTMB_OVERFLOW_BOUNDARY_TESTS)) +def test_autoCompact_fstmb_overflow(database_client, collection, test): + """Test autoCompact freeSpaceTargetMB MB-to-bytes overflow boundary behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Ensure autoCompact is idle first: a leftover config from a prior test + # would otherwise conflict. + ensure_autocompact_idle(collection) + result = execute_admin_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/system/administration/commands/autoCompact/test_autoCompact_operational.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py new file mode 100644 index 000000000..1f1b12955 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_operational.py @@ -0,0 +1,78 @@ +"""Tests for autoCompact operational constraints: admin scope and reconfigure conflict.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CONFLICTING_OPERATION_IN_PROGRESS_ERROR, + UNAUTHORIZED_ERROR, +) +from documentdb_tests.framework.executor import ( + execute_admin_command, + execute_command, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Admin-Scope Errors]: a fully valid autoCompact run against a +# non-admin database is rejected as out of scope. +AUTOCOMPACT_ADMIN_SCOPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "admin_scope_enable", + command=lambda ctx: {"autoCompact": True}, + error_code=UNAUTHORIZED_ERROR, + msg="autoCompact enable should be rejected when run against a non-admin database", + ), + CommandTestCase( + "admin_scope_disable", + command=lambda ctx: {"autoCompact": False}, + error_code=UNAUTHORIZED_ERROR, + msg="autoCompact disable should be rejected when run against a non-admin database", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_ADMIN_SCOPE_TESTS)) +def test_autoCompact_admin_scope(database_client, collection, test): + """Test autoCompact admin-scope rejection against a non-admin database.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Run against the fixture's non-admin database so the admin-scope check can + # fire. It happens at dispatch, independent of compaction state, so no + # settling is needed here. + 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 [Running-State Reconfigure Conflict]: reconfiguring an enabled +# autoCompact with a different config is rejected with a conflict error rather +# than silently overriding the running config. +@pytest.mark.no_parallel +def test_autoCompact_reconfigure_conflict(collection): + """Test autoCompact rejects a differing reconfigure while enabled.""" + ensure_autocompact_idle(collection) + # Establish a running config. This enable reliably succeeds from the idle + # state ensure_autocompact_idle guarantees, installing a persistent config + # that the differing enable below then conflicts with. + execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 30}) + result = execute_admin_command(collection, {"autoCompact": True, "freeSpaceTargetMB": 50}) + assertResult( + result, + error_code=CONFLICTING_OPERATION_IN_PROGRESS_ERROR, + msg="autoCompact should reject reconfiguring an enabled compaction with a different config", + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py new file mode 100644 index 000000000..4ed6528fc --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_request_validation.py @@ -0,0 +1,221 @@ +"""Tests for autoCompact request validation: type strictness, null, and bad fields.""" + +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.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DOUBLE_ZERO, INT32_ZERO + +# Property [Null Command Value]: a null autoCompact command value is treated as +# a missing required field rather than a wrong type. +AUTOCOMPACT_NULL_COMMAND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_command_value", + command=lambda ctx: {"autoCompact": None}, + error_code=MISSING_FIELD_ERROR, + msg="autoCompact should reject a null command value as a missing required field", + ), +] + +# Property [Value Type Strictness]: every non-bool BSON type for the autoCompact +# command value is rejected with a type mismatch error rather than coerced to a +# boolean, and a literal array is rejected without unwrapping. +AUTOCOMPACT_VALUE_TYPE_STRICTNESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"value_type_{tid}", + command=lambda ctx, v=val: {"autoCompact": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"autoCompact should reject a {tid} command value as the wrong type", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("string", "true"), + ("object", {"a": 1}), + ("array", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("x")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "value_array_single_bool", + command=lambda ctx: {"autoCompact": [True]}, + error_code=TYPE_MISMATCH_ERROR, + msg="autoCompact should reject a single-element bool array without unwrapping it", + ), +] + +# Property [freeSpaceTargetMB Type Strictness]: every non-numeric BSON type for +# freeSpaceTargetMB is rejected with a type mismatch error rather than coerced +# to a number, and a literal array is rejected without unwrapping. +AUTOCOMPACT_FSTMB_TYPE_STRICTNESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"fstmb_type_{tid}", + command=lambda ctx, v=val: {"autoCompact": True, "freeSpaceTargetMB": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"autoCompact should reject a {tid} freeSpaceTargetMB as the wrong type", + ) + for tid, val in [ + ("string", "20"), + ("bool", True), + ("object", {"a": 1}), + ("array", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("x")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "fstmb_array_single_int", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": [20]}, + error_code=TYPE_MISMATCH_ERROR, + msg="autoCompact should reject a single-element int array freeSpaceTargetMB without unwrap", + ), +] + +# Property [runOnce Type Strictness]: every non-bool BSON type for runOnce is +# rejected with a type mismatch error rather than coerced to a boolean, so +# numeric 0 and 1 are not accepted. +AUTOCOMPACT_RUNONCE_TYPE_STRICTNESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"runonce_type_{tid}", + command=lambda ctx, v=val: {"autoCompact": True, "runOnce": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"autoCompact should reject a {tid} runOnce as the wrong type", + ) + for tid, val in [ + ("string", "true"), + ("int32_zero", INT32_ZERO), + ("int32_one", 1), + ("int64", Int64(1)), + ("double_zero", DOUBLE_ZERO), + ("double_one", 1.0), + ("decimal128", Decimal128("1")), + ("object", {"$exists": True}), + ("array", []), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("x")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "runonce_array_single_bool", + command=lambda ctx: {"autoCompact": True, "runOnce": [True]}, + error_code=TYPE_MISMATCH_ERROR, + msg="autoCompact should reject a single-element bool array runOnce without unwrapping it", + ), +] + +# Property [Unknown Field Handling]: an unknown top-level field is rejected with +# an unknown-field error, and known option names are case-sensitive so wrong-case +# variants are treated as unknown fields and rejected the same way. +AUTOCOMPACT_UNKNOWN_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field", + command=lambda ctx: {"autoCompact": True, "bogusField": 1}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="autoCompact should reject an unknown top-level field", + ), + CommandTestCase( + "unknown_field_fstmb_capitalized", + command=lambda ctx: {"autoCompact": True, "FreeSpaceTargetMB": 20}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="autoCompact should treat a wrong-case variant of a known option as an unknown field", + ), +] + +# Property [Generic Envelope Field Rejection]: a writeConcern envelope field is +# rejected with an unsupported-options error on both the enable and disable +# paths. +AUTOCOMPACT_WRITE_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "write_concern_enable", + command=lambda ctx: {"autoCompact": True, "writeConcern": {"w": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="autoCompact enable should reject writeConcern as an unsupported envelope field", + ), + CommandTestCase( + "write_concern_disable", + command=lambda ctx: {"autoCompact": False, "writeConcern": {"w": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="autoCompact disable should reject writeConcern as an unsupported envelope field", + ), +] + +AUTOCOMPACT_REQUEST_VALIDATION_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_NULL_COMMAND_TESTS + + AUTOCOMPACT_VALUE_TYPE_STRICTNESS_TESTS + + AUTOCOMPACT_FSTMB_TYPE_STRICTNESS_TESTS + + AUTOCOMPACT_RUNONCE_TYPE_STRICTNESS_TESTS + + AUTOCOMPACT_UNKNOWN_FIELD_TESTS + + AUTOCOMPACT_WRITE_CONCERN_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_REQUEST_VALIDATION_TESTS)) +def test_autoCompact_request_validation(database_client, collection, test): + """Test autoCompact rejection of malformed requests (type, null, bad fields).""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Ensure autoCompact is idle first: a leftover config from a prior test + # would otherwise conflict. + ensure_autocompact_idle(collection) + result = execute_admin_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/system/administration/commands/autoCompact/test_autoCompact_success.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py new file mode 100644 index 000000000..b24e6c915 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_autoCompact_success.py @@ -0,0 +1,126 @@ +"""Tests for the autoCompact command: successful requests and accepted inputs.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, IsType, NotExists + +# Property [Response Format]: a successful autoCompact returns a bare ok +# response with no command-specific result fields. +AUTOCOMPACT_RESPONSE_FORMAT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "response_enable", + command=lambda ctx: {"autoCompact": True}, + expected={ + "ok": [Eq(1.0), IsType("double")], + "autoCompact": NotExists(), + }, + msg="autoCompact enable should return ok:1.0 as a double with no command-specific fields", + ), + CommandTestCase( + "response_disable", + command=lambda ctx: {"autoCompact": False}, + expected={ + "ok": [Eq(1.0), IsType("double")], + "autoCompact": NotExists(), + }, + msg="autoCompact disable should return ok:1.0 as a double with no command-specific fields", + ), + CommandTestCase( + "response_enable_with_options", + command=lambda ctx: { + "autoCompact": True, + "freeSpaceTargetMB": 1, + "runOnce": False, + }, + expected={ + "ok": [Eq(1.0), IsType("double")], + "autoCompact": NotExists(), + "freeSpaceTargetMB": NotExists(), + "runOnce": NotExists(), + }, + msg="autoCompact enable with options should not echo freeSpaceTargetMB or runOnce", + ), +] + +# Property [Null Optional Fields]: a null freeSpaceTargetMB or runOnce is +# accepted and treated identically to omitting the field. +AUTOCOMPACT_NULL_OPTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_fstmb", + command=lambda ctx: {"autoCompact": True, "freeSpaceTargetMB": None}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should treat a null freeSpaceTargetMB as omitted", + ), + CommandTestCase( + "null_runonce", + command=lambda ctx: {"autoCompact": True, "runOnce": None}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should treat a null runOnce as omitted", + ), +] + +# Property [Value Behavior]: runOnce:true is accepted alongside a disable +# (autoCompact:false) rather than rejected as contradictory. +AUTOCOMPACT_VALUE_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "value_disable_with_runonce", + command=lambda ctx: {"autoCompact": False, "runOnce": True}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept runOnce:true alongside a disable and return ok:1.0", + ), +] + +# Property [runOnce Accepted Values]: both runOnce:true and runOnce:false are +# accepted on the enable path. +AUTOCOMPACT_RUNONCE_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "runonce_true", + command=lambda ctx: {"autoCompact": True, "runOnce": True}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept runOnce:true and return ok:1.0", + ), + CommandTestCase( + "runonce_false", + command=lambda ctx: {"autoCompact": True, "runOnce": False}, + expected={"ok": Eq(1.0)}, + msg="autoCompact should accept runOnce:false and return ok:1.0", + ), +] + +AUTOCOMPACT_SUCCESS_TESTS: list[CommandTestCase] = ( + AUTOCOMPACT_RESPONSE_FORMAT_TESTS + + AUTOCOMPACT_NULL_OPTIONAL_TESTS + + AUTOCOMPACT_VALUE_BEHAVIOR_TESTS + + AUTOCOMPACT_RUNONCE_ACCEPTED_TESTS +) + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(AUTOCOMPACT_SUCCESS_TESTS)) +def test_autoCompact_success(database_client, collection, test): + """Test autoCompact successful requests and accepted inputs.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + # Ensure autoCompact is idle first: a leftover config from a prior test + # would otherwise conflict. + ensure_autocompact_idle(collection) + result = execute_admin_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/system/administration/commands/autoCompact/test_smoke_autoCompact.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py index 06d752df2..5bb8b7954 100644 --- a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/test_smoke_autoCompact.py @@ -6,14 +6,20 @@ import pytest +from documentdb_tests.compatibility.tests.system.administration.commands.autoCompact.utils.autoCompact_common import ( # noqa: E501 + ensure_autocompact_idle, +) from documentdb_tests.framework.assertions import assertSuccessPartial from documentdb_tests.framework.executor import execute_admin_command -pytestmark = pytest.mark.smoke +pytestmark = [pytest.mark.smoke, pytest.mark.no_parallel] def test_smoke_autoCompact(collection): """Test basic autoCompact behavior.""" + # Ensure autoCompact is idle first: a leftover non-default config would make + # this plain enable conflict instead of returning ok. + ensure_autocompact_idle(collection) result = execute_admin_command(collection, {"autoCompact": True}) expected = {"ok": 1.0} diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/__init__.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py new file mode 100644 index 000000000..4b325ed7a --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/administration/commands/autoCompact/utils/autoCompact_common.py @@ -0,0 +1,33 @@ +"""Shared helpers for autoCompact command tests.""" + +from __future__ import annotations + +import time + +from documentdb_tests.framework.executor import execute_admin_command + + +def ensure_autocompact_idle(collection): + """Disable autoCompact, retrying until it reaches a deterministic idle state. + + autoCompact is a server-wide setting, so a test inherits prior state, and a + single disable returns before the background wind-down finishes. This sends + disable repeatedly with a short pause between calls, returns only after + several consecutive disables succeed (so the async wind-down has time to + finish), and raises if it never settles within a bounded number of attempts. + + Callers must be marked no_parallel: this only resets state left by a prior + test, not a concurrent worker mutating the shared setting between settling + and the command under test. + """ + consecutive = 0 + for _ in range(200): + result = execute_admin_command(collection, {"autoCompact": False}) + if isinstance(result, dict) and result.get("ok") == 1.0: + consecutive += 1 + if consecutive >= 3: + return + else: + consecutive = 0 + time.sleep(0.05) + raise RuntimeError("autoCompact did not reach an idle state") diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 2375b9dcd..971e39e69 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -12,6 +12,7 @@ OVERFLOW_ERROR = 15 INVALID_LENGTH_ERROR = 16 ILLEGAL_OPERATION_ERROR = 20 +CONFLICTING_OPERATION_IN_PROGRESS_ERROR = 23 NAMESPACE_NOT_FOUND_ERROR = 26 INDEX_NOT_FOUND_ERROR = 27 PATH_NOT_VIABLE_ERROR = 28