| """Adversarial fuzz tests for SibylStore. |
| |
| Target malformed inputs across all public methods. Hunt for: |
| - Crashes that leak raw internal tracebacks instead of clean typed errors. |
| - Silent state corruption or data loss. |
| - Boundary violations (None where types are strict, nested structures that break assumptions). |
| |
| Each test category has a minimal repro. Tests run against an isolated temp DB. |
| """ |
|
|
| import os |
| import random |
| import string |
| import tempfile |
| from datetime import datetime, timezone |
| from typing import Any |
|
|
| import pytest |
| from langgraph.store.base import GetOp, ListNamespacesOp, PutOp, SearchOp |
|
|
| from sibyl_memory_langgraph import SibylStore |
|
|
| try: |
| from sibyl_memory_client.exceptions import ValidationError |
| except ImportError: |
| ValidationError = ValueError |
|
|
|
|
| |
| def _fresh_store(): |
| """Create an isolated store with a temp DB.""" |
| tmpdir = tempfile.mkdtemp() |
| db_path = os.path.join(tmpdir, "test.db") |
| return SibylStore(path=db_path, tier="free") |
|
|
|
|
| |
| class TestNamespaceMalformed: |
| """Namespace validation: must be tuple of non-empty strings, no "/" or "..".""" |
|
|
| def test_namespace_plain_string(self): |
| """Namespace as plain string: FIXED - now rejected with clean ValueError. |
| |
| Previously a bare str was silently coerced to a tuple of chars |
| ("users" -> ("u","s","e","r","s")). Fixed: _ensure_ns_seq rejects |
| bare str/bytes and any non-tuple/list with ValueError, no char-coercion. |
| """ |
| store = _fresh_store() |
| with pytest.raises(ValueError, match="namespace must be a tuple of strings"): |
| store.batch([PutOp(namespace="users", key="k1", value={})]) |
| |
| result = store.batch([GetOp(namespace=("u", "s", "e", "r", "s"), key="k1")]) |
| assert result[0] is None |
|
|
| def test_namespace_list(self): |
| """Namespace as list: ACCEPTED by design (tuple/list both allowed).""" |
| store = _fresh_store() |
| |
| |
| |
| store.batch([PutOp(namespace=["users"], key="k1", value={})]) |
| result = store.batch([GetOp(namespace=("users",), key="k1")]) |
| assert result[0] is not None |
|
|
| def test_namespace_none(self): |
| """Namespace as None should raise TypeError or ValueError.""" |
| store = _fresh_store() |
| with pytest.raises((TypeError, ValueError)): |
| store.batch([PutOp(namespace=None, key="k1", value={})]) |
|
|
| def test_namespace_int(self): |
| """Namespace as int should raise TypeError or ValueError.""" |
| store = _fresh_store() |
| with pytest.raises((TypeError, ValueError)): |
| store.batch([PutOp(namespace=1, key="k1", value={})]) |
|
|
| def test_namespace_empty_tuple(self): |
| """Empty tuple should raise ValueError.""" |
| store = _fresh_store() |
| with pytest.raises(ValueError, match="non-empty tuple"): |
| store.batch([PutOp(namespace=(), key="k1", value={})]) |
|
|
| def test_namespace_element_empty_string(self): |
| """Tuple with empty string element should raise ValueError.""" |
| store = _fresh_store() |
| with pytest.raises(ValueError, match="non-empty strings"): |
| store.batch([PutOp(namespace=("users", ""), key="k1", value={})]) |
|
|
| def test_namespace_element_with_slash(self): |
| """Tuple element containing "/" should raise ValueError.""" |
| store = _fresh_store() |
| with pytest.raises(ValueError, match=r"may not contain '/'"): |
| store.batch([PutOp(namespace=("users/admin", "x"), key="k1", value={})]) |
|
|
| def test_namespace_element_with_dotdot(self): |
| """Tuple element containing ".." should raise ValueError.""" |
| store = _fresh_store() |
| with pytest.raises(ValueError, match=r"may not contain '\.\.'"): |
| store.batch([PutOp(namespace=("users", ".."), key="k1", value={})]) |
|
|
| def test_namespace_element_non_string_int(self): |
| """Tuple element that is an int should raise ValueError.""" |
| store = _fresh_store() |
| with pytest.raises(ValueError, match="non-empty strings"): |
| store.batch([PutOp(namespace=("users", 1), key="k1", value={})]) |
|
|
| def test_namespace_element_non_string_none(self): |
| """Tuple element that is None should raise ValueError.""" |
| store = _fresh_store() |
| with pytest.raises(ValueError, match="non-empty strings"): |
| store.batch([PutOp(namespace=("users", None), key="k1", value={})]) |
|
|
| def test_namespace_element_nested_tuple(self): |
| """Tuple element that is a tuple should raise ValueError.""" |
| store = _fresh_store() |
| with pytest.raises(ValueError, match="non-empty strings"): |
| store.batch([PutOp(namespace=("users", ("nested",)), key="k1", value={})]) |
|
|
| def test_namespace_element_bytes(self): |
| """Tuple element that is bytes should raise ValueError.""" |
| store = _fresh_store() |
| with pytest.raises(ValueError, match="non-empty strings"): |
| store.batch([PutOp(namespace=("users", b"data"), key="k1", value={})]) |
|
|
|
|
| |
| class TestKeyMalformed: |
| """Key validation: must be a string.""" |
|
|
| def test_key_none(self): |
| """Key as None should raise ValidationError from client.""" |
| store = _fresh_store() |
| |
| with pytest.raises((ValidationError, TypeError, ValueError, AttributeError)): |
| store.batch([PutOp(namespace=("users",), key=None, value={})]) |
|
|
| def test_key_int(self): |
| """Key as int should raise ValidationError.""" |
| store = _fresh_store() |
| with pytest.raises((ValidationError, TypeError, ValueError)): |
| store.batch([PutOp(namespace=("users",), key=123, value={})]) |
|
|
| def test_key_empty_string(self): |
| """Key as empty string: ValidationError from client.""" |
| store = _fresh_store() |
| with pytest.raises((ValidationError, ValueError)): |
| store.batch([PutOp(namespace=("users",), key="", value={"data": "test"})]) |
|
|
| def test_key_bytes(self): |
| """Key as bytes should raise ValidationError.""" |
| store = _fresh_store() |
| with pytest.raises((ValidationError, TypeError)): |
| store.batch([PutOp(namespace=("users",), key=b"key", value={})]) |
|
|
| def test_key_very_long(self): |
| """Very long key (10K chars) should raise ValidationError.""" |
| store = _fresh_store() |
| long_key = "k" * 10000 |
| with pytest.raises((ValidationError, ValueError)): |
| store.batch([PutOp(namespace=("users",), key=long_key, value={})]) |
|
|
| def test_key_with_newline(self): |
| """Key with newline: ValidationError from client (control char check).""" |
| store = _fresh_store() |
| key_with_newline = "key\nwith\nnewline" |
| with pytest.raises((ValidationError, ValueError)): |
| store.batch([PutOp(namespace=("users",), key=key_with_newline, value={"x": 1})]) |
|
|
| def test_key_with_null_byte(self): |
| """Key with null byte: ValidationError from client (control char check).""" |
| store = _fresh_store() |
| key_with_null = "key\x00null" |
| with pytest.raises((ValidationError, ValueError)): |
| store.batch([PutOp(namespace=("users",), key=key_with_null, value={})]) |
|
|
|
|
| |
| class TestValueMalformed: |
| """Value validation: must be dict, or None (delete sentinel).""" |
|
|
| def test_value_string(self): |
| """Value as string (not dict) should raise ValidationError.""" |
| store = _fresh_store() |
| with pytest.raises((ValidationError, TypeError, ValueError)): |
| store.batch([PutOp(namespace=("users",), key="k1", value="string")]) |
|
|
| def test_value_int(self): |
| """Value as int should raise ValidationError.""" |
| store = _fresh_store() |
| with pytest.raises((ValidationError, TypeError, ValueError)): |
| store.batch([PutOp(namespace=("users",), key="k1", value=42)]) |
|
|
| def test_value_list(self): |
| """Value as list: ACCEPTED (lists are valid containers).""" |
| store = _fresh_store() |
| |
| store.batch([PutOp(namespace=("users",), key="k1", value=[1, 2, 3])]) |
| item = store.batch([GetOp(namespace=("users",), key="k1")]) |
| assert item[0] is not None |
| assert item[0].value == [1, 2, 3] |
|
|
| def test_value_none_is_delete(self): |
| """Value as None is the delete sentinel (valid, not an error).""" |
| store = _fresh_store() |
| |
| store.batch([PutOp(namespace=("users",), key="k1", value={"data": "test"})]) |
| item = store.batch([GetOp(namespace=("users",), key="k1")]) |
| assert item[0] is not None |
| |
| store.batch([PutOp(namespace=("users",), key="k1", value=None)]) |
| item = store.batch([GetOp(namespace=("users",), key="k1")]) |
| assert item[0] is None |
|
|
| def test_value_dict_non_string_keys(self): |
| """Dict with non-string keys: may be coerced or rejected cleanly.""" |
| store = _fresh_store() |
| try: |
| store.batch([PutOp(namespace=("users",), key="k1", value={1: "one", 2: "two"})]) |
| |
| item = store.batch([GetOp(namespace=("users",), key="k1")]) |
| except (TypeError, ValueError) as e: |
| pass |
|
|
| def test_value_dict_bytes_values(self): |
| """Dict with bytes values: ValidationError from JSON serialization.""" |
| store = _fresh_store() |
| with pytest.raises((ValidationError, TypeError, ValueError)): |
| store.batch([PutOp(namespace=("users",), key="k1", value={"data": b"bytes"})]) |
|
|
| def test_value_deeply_nested_dict(self): |
| """Deeply nested dict structure: should be accepted (valid JSON-able).""" |
| store = _fresh_store() |
| deep = {"a": {"b": {"c": {"d": {"e": "value"}}}}} |
| store.batch([PutOp(namespace=("users",), key="k1", value=deep)]) |
| item = store.batch([GetOp(namespace=("users",), key="k1")]) |
| assert item[0] is not None |
| assert item[0].value == deep |
|
|
|
|
| |
| class TestFilterMalformed: |
| """Filter validation: must be dict or None, with valid operators.""" |
|
|
| def setup_method(self): |
| """Pre-populate store with test data for search.""" |
| self.store = _fresh_store() |
| self.store.batch([ |
| PutOp(namespace=("users",), key="u1", value={"age": 25, "name": "Alice"}), |
| PutOp(namespace=("users",), key="u2", value={"age": 30, "name": "Bob"}), |
| ]) |
|
|
| def test_filter_string(self): |
| """Filter as string: FIXED - now clean ValueError instead of AttributeError. |
| |
| Previously _match_filter called flt.items() on a str and leaked a raw |
| AttributeError. Fixed: _match_filter raises ValueError for non-dict filters. |
| """ |
| with pytest.raises(ValueError, match="filter must be a dict or None"): |
| self.store.batch([SearchOp(namespace_prefix=("users",), query="Alice", filter="invalid")]) |
|
|
| def test_filter_list(self): |
| """Filter as list: FIXED - now clean ValueError instead of AttributeError.""" |
| with pytest.raises(ValueError, match="filter must be a dict or None"): |
| self.store.batch([SearchOp(namespace_prefix=("users",), query="Alice", filter=["age", 25])]) |
|
|
| def test_filter_unknown_operator(self): |
| """Filter with unknown operator like $and or $regex should raise ValueError.""" |
| with pytest.raises(ValueError, match="unsupported filter operator"): |
| self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="Alice", |
| filter={"age": {"$regex": "^25"}} |
| )]) |
|
|
| def test_filter_operator_exists(self): |
| """Filter with $exists operator (not in supported ops) should raise ValueError.""" |
| with pytest.raises(ValueError, match="unsupported filter operator"): |
| self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="Alice", |
| filter={"name": {"$exists": True}} |
| )]) |
|
|
| def test_filter_valid_eq(self): |
| """Valid $eq filter should work.""" |
| results = self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="", |
| filter={"age": {"$eq": 25}} |
| )]) |
| assert len(results[0]) > 0 |
|
|
| def test_filter_valid_in(self): |
| """Valid $in filter should work.""" |
| results = self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="", |
| filter={"age": {"$in": [25, 30]}} |
| )]) |
| assert len(results[0]) > 0 |
|
|
| def test_filter_mixed_valid_invalid(self): |
| """Filter with both valid and invalid operators should raise ValueError on first invalid.""" |
| with pytest.raises(ValueError, match="unsupported filter operator"): |
| self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="", |
| filter={"age": {"$eq": 25, "$badop": "value"}} |
| )]) |
|
|
|
|
| |
| class TestSearchMalformed: |
| """Search parameter validation: limit, offset, namespace_prefix.""" |
|
|
| def setup_method(self): |
| """Pre-populate store with test data.""" |
| self.store = _fresh_store() |
| for i in range(5): |
| self.store.batch([ |
| PutOp(namespace=("users",), key=f"u{i}", value={"id": i}) |
| ]) |
|
|
| def test_search_negative_limit(self): |
| """Negative limit should be handled (clamped, or rejected cleanly).""" |
| try: |
| results = self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="", |
| limit=-10 |
| )]) |
| |
| assert isinstance(results[0], list) |
| except (ValueError, TypeError) as e: |
| pass |
|
|
| def test_search_zero_limit(self): |
| """Zero limit should return empty list (valid edge case).""" |
| results = self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="", |
| limit=0 |
| )]) |
| assert results[0] == [] |
|
|
| def test_search_huge_limit(self): |
| """Very large limit (10^9) should not crash, just return all available.""" |
| results = self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="", |
| limit=10**9 |
| )]) |
| assert isinstance(results[0], list) |
| assert len(results[0]) <= 5 |
|
|
| def test_search_negative_offset(self): |
| """Negative offset should be handled.""" |
| try: |
| results = self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="", |
| offset=-5 |
| )]) |
| assert isinstance(results[0], list) |
| except (ValueError, TypeError) as e: |
| pass |
|
|
| def test_search_huge_offset(self): |
| """Huge offset should return empty list.""" |
| results = self.store.batch([SearchOp( |
| namespace_prefix=("users",), |
| query="", |
| offset=10**9 |
| )]) |
| assert results[0] == [] |
|
|
| def test_search_namespace_prefix_string(self): |
| """namespace_prefix as string: FIXED - now rejected with clean ValueError. |
| |
| Previously "users" was silently coerced to ("u","s","e","r","s") and the |
| search ran against the wrong prefix. Fixed: _validate_prefix routes through |
| _ensure_ns_seq, which rejects a bare str with ValueError. |
| """ |
| with pytest.raises(ValueError, match="namespace must be a tuple of strings"): |
| self.store.batch([SearchOp( |
| namespace_prefix="users", |
| query="" |
| )]) |
|
|
| def test_search_namespace_prefix_with_slash(self): |
| """namespace_prefix element with "/" should raise ValueError.""" |
| with pytest.raises(ValueError, match=r"may not contain '/'"): |
| self.store.batch([SearchOp( |
| namespace_prefix=("users/admin",), |
| query="" |
| )]) |
|
|
| def test_search_empty_namespace_prefix_is_valid(self): |
| """Empty namespace_prefix (empty tuple) should search all namespaces.""" |
| results = self.store.batch([SearchOp( |
| namespace_prefix=(), |
| query="" |
| )]) |
| assert isinstance(results[0], list) |
|
|
|
|
| |
| class TestListNamespacesMalformed: |
| """ListNamespacesOp edge cases.""" |
|
|
| def setup_method(self): |
| """Pre-populate store with test data.""" |
| self.store = _fresh_store() |
| self.store.batch([ |
| PutOp(namespace=("users", "profile"), key="u1", value={"x": 1}), |
| PutOp(namespace=("posts",), key="p1", value={"x": 2}), |
| ]) |
|
|
| def test_list_namespaces_negative_limit(self): |
| """Negative limit should be handled.""" |
| try: |
| results = self.store.batch([ListNamespacesOp(limit=-1)]) |
| assert isinstance(results[0], list) |
| except (ValueError, TypeError) as e: |
| pass |
|
|
| def test_list_namespaces_zero_limit(self): |
| """Zero limit should return empty list.""" |
| results = self.store.batch([ListNamespacesOp(limit=0)]) |
| assert results[0] == [] |
|
|
| def test_list_namespaces_huge_limit(self): |
| """Huge limit should return all available.""" |
| results = self.store.batch([ListNamespacesOp(limit=10**9)]) |
| assert isinstance(results[0], list) |
|
|
| def test_list_namespaces_negative_offset(self): |
| """Negative offset should be handled.""" |
| try: |
| results = self.store.batch([ListNamespacesOp(offset=-1)]) |
| assert isinstance(results[0], list) |
| except (ValueError, TypeError) as e: |
| pass |
|
|
| def test_list_namespaces_huge_offset(self): |
| """Huge offset should return empty list.""" |
| results = self.store.batch([ListNamespacesOp(offset=10**9)]) |
| assert results[0] == [] |
|
|
| def test_list_namespaces_negative_max_depth(self): |
| """Negative max_depth should be handled.""" |
| try: |
| results = self.store.batch([ListNamespacesOp(max_depth=-1)]) |
| assert isinstance(results[0], list) |
| except (ValueError, TypeError) as e: |
| pass |
|
|
| def test_list_namespaces_zero_max_depth(self): |
| """Zero max_depth: should truncate all namespaces to zero elements (all empty tuples -> one ()).""" |
| results = self.store.batch([ListNamespacesOp(max_depth=0)]) |
| |
| assert results[0] == [()] |
|
|
|
|
| |
| class TestBatchOperationSequences: |
| """Run sequences of valid ops to verify state consistency and no silent corruption.""" |
|
|
| def test_put_get_delete_sequence(self): |
| """Put, Get, Delete, Get sequence should maintain state.""" |
| store = _fresh_store() |
| ns = ("users",) |
| key = "u1" |
| value = {"age": 30} |
|
|
| |
| store.batch([PutOp(namespace=ns, key=key, value=value)]) |
|
|
| |
| items = store.batch([GetOp(namespace=ns, key=key)]) |
| assert items[0] is not None |
| assert items[0].value == value |
|
|
| |
| store.batch([PutOp(namespace=ns, key=key, value=None)]) |
|
|
| |
| items = store.batch([GetOp(namespace=ns, key=key)]) |
| assert items[0] is None |
|
|
| def test_concurrent_ops_same_batch(self): |
| """Multiple ops in one batch should execute atomically.""" |
| store = _fresh_store() |
| ops = [ |
| PutOp(namespace=("users",), key="u1", value={"name": "Alice"}), |
| PutOp(namespace=("users",), key="u2", value={"name": "Bob"}), |
| PutOp(namespace=("posts",), key="p1", value={"title": "Hello"}), |
| GetOp(namespace=("users",), key="u1"), |
| SearchOp(namespace_prefix=("users",), query="Alice"), |
| ] |
| results = store.batch(ops) |
| assert results[3] is not None |
| assert isinstance(results[4], list) |
|
|
| def test_overwrite_same_key(self): |
| """Multiple puts to same key should keep only latest value.""" |
| store = _fresh_store() |
| ns = ("users",) |
| key = "u1" |
|
|
| store.batch([ |
| PutOp(namespace=ns, key=key, value={"version": 1}), |
| PutOp(namespace=ns, key=key, value={"version": 2}), |
| PutOp(namespace=ns, key=key, value={"version": 3}), |
| ]) |
|
|
| items = store.batch([GetOp(namespace=ns, key=key)]) |
| assert items[0].value == {"version": 3} |
|
|
| def test_random_ops_fixed_seed(self): |
| """Generate 50 random valid ops with fixed seed and verify no crash/corruption.""" |
| random.seed(42) |
| store = _fresh_store() |
|
|
| ns_options = [ |
| ("users",), |
| ("posts",), |
| ("users", "profile"), |
| ("data", "archive"), |
| ] |
|
|
| ops = [] |
| for _ in range(50): |
| op_type = random.choice(["put", "get", "search", "list"]) |
| ns = random.choice(ns_options) |
| key = f"key_{random.randint(0, 10)}" |
|
|
| if op_type == "put": |
| value = { |
| "field1": f"value_{random.randint(0, 100)}", |
| "field2": random.randint(0, 1000), |
| } |
| ops.append(PutOp(namespace=ns, key=key, value=value)) |
| elif op_type == "get": |
| ops.append(GetOp(namespace=ns, key=key)) |
| elif op_type == "search": |
| query = random.choice(["", "test", "data"]) |
| ops.append(SearchOp(namespace_prefix=ns, query=query, limit=10)) |
| else: |
| ops.append(ListNamespacesOp(limit=20)) |
|
|
| |
| results = store.batch(ops) |
| assert len(results) == len(ops) |
|
|
|
|
| |
|
|
| class TestEdgeCases: |
| """Boundary conditions and special cases.""" |
|
|
| def test_special_characters_in_key(self): |
| """Key with special characters should work.""" |
| store = _fresh_store() |
| special_key = "key!@#$%^&*()" |
| store.batch([PutOp(namespace=("users",), key=special_key, value={"x": 1})]) |
| items = store.batch([GetOp(namespace=("users",), key=special_key)]) |
| assert items[0] is not None |
|
|
| def test_unicode_in_namespace_and_key(self): |
| """Unicode in namespace element and key should work.""" |
| store = _fresh_store() |
| store.batch([PutOp(namespace=("用户",), key="キー", value={"x": 1})]) |
| items = store.batch([GetOp(namespace=("用户",), key="キー")]) |
| assert items[0] is not None |
|
|
| def test_empty_value_dict(self): |
| """Empty dict as value should be valid.""" |
| store = _fresh_store() |
| store.batch([PutOp(namespace=("users",), key="u1", value={})]) |
| items = store.batch([GetOp(namespace=("users",), key="u1")]) |
| assert items[0] is not None |
| assert items[0].value == {} |
|
|
| def test_deeply_nested_value(self): |
| """Very deep nesting should work.""" |
| store = _fresh_store() |
| deep = {"a": {}} |
| current = deep["a"] |
| for i in range(20): |
| current[f"level{i}"] = {} |
| current = current[f"level{i}"] |
| current["value"] = "deep" |
|
|
| store.batch([PutOp(namespace=("users",), key="u1", value=deep)]) |
| items = store.batch([GetOp(namespace=("users",), key="u1")]) |
| assert items[0] is not None |
|
|
| def test_search_with_none_query(self): |
| """Search with None query should behave like empty string.""" |
| store = _fresh_store() |
| store.batch([PutOp(namespace=("users",), key="u1", value={"x": 1})]) |
| results = store.batch([SearchOp(namespace_prefix=("users",), query=None)]) |
| assert isinstance(results[0], list) |
|
|
| def test_context_manager(self): |
| """Store used as context manager should clean up.""" |
| with _fresh_store() as store: |
| store.batch([PutOp(namespace=("users",), key="u1", value={"x": 1})]) |
| items = store.batch([GetOp(namespace=("users",), key="u1")]) |
| assert items[0] is not None |
| |
|
|
| def test_no_state_bleed_between_stores(self): |
| """Two separate stores with different DBs should not share data.""" |
| store1 = _fresh_store() |
| store2 = _fresh_store() |
|
|
| store1.batch([PutOp(namespace=("users",), key="u1", value={"src": "store1"})]) |
|
|
| items = store2.batch([GetOp(namespace=("users",), key="u1")]) |
| assert items[0] is None |
|
|
|
|
| |
| class TestBatchInputMalformed: |
| """Malformed inputs to batch() itself.""" |
|
|
| def test_batch_with_none_in_ops(self): |
| """Batch ops list containing None should raise NotImplementedError.""" |
| store = _fresh_store() |
| with pytest.raises((NotImplementedError, TypeError, AttributeError)): |
| store.batch([PutOp(namespace=("users",), key="u1", value={}), None]) |
|
|
| def test_batch_with_wrong_op_type(self): |
| """Batch with an unrecognized op type should raise NotImplementedError or TypeError.""" |
| store = _fresh_store() |
| with pytest.raises((NotImplementedError, TypeError, AttributeError)): |
| store.batch([ |
| PutOp(namespace=("users",), key="u1", value={}), |
| "not_an_op", |
| ]) |
|
|
| def test_batch_with_dict_instead_of_op(self): |
| """Batch with a dict (not an Op object) should raise TypeError or NotImplementedError.""" |
| store = _fresh_store() |
| with pytest.raises((NotImplementedError, TypeError, AttributeError)): |
| store.batch([ |
| PutOp(namespace=("users",), key="u1", value={}), |
| {"namespace": ("users",), "key": "u2", "value": {}}, |
| ]) |
|
|
| def test_batch_empty_list(self): |
| """Batch with empty list should return empty results.""" |
| store = _fresh_store() |
| results = store.batch([]) |
| assert results == [] |
|
|
| def test_batch_generator_instead_of_list(self): |
| """Batch should accept any iterable, including generators.""" |
| store = _fresh_store() |
| def gen(): |
| yield PutOp(namespace=("users",), key="u1", value={"x": 1}) |
| yield GetOp(namespace=("users",), key="u1") |
|
|
| results = store.batch(gen()) |
| assert len(results) == 2 |
| assert results[1] is not None |
|
|
|
|
| if __name__ == "__main__": |
| pytest.main([__file__, "-v"]) |
|
|