mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
0811c48252
Codex review of PR #962 identified two real issues: 1. CLI bypass: the BeforeValidator(parse_str_list) on note_types, entity_types, and categories only fires through MCP/Pydantic validation. The CLI path in cli/commands/tool.py calls search_notes() directly, so `bm tool search-notes --type note,task` arrived as note_types=["note,task"] and matched nothing. Fix: add in-body parse_str_list() normalization for all three params (mirroring the existing parse_tags() call for tags on the same code path). 2. Silent stringify: parse_str_list used str(raw) in the list branch, so [42] became ["42"] before Pydantic saw it, accepting invalid input as a no-result search instead of rejecting it. Fix: guard against non-string list elements and return the original value unchanged so Pydantic rejects it with a clear error. Tests added: annotation-level split tests for note_types/entity_types/categories, non-string-element rejection tests, async direct-call regression for note_types, and unit-level parse_str_list non-string list tests in test_coerce.py. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
195 lines
7.2 KiB
Python
195 lines
7.2 KiB
Python
"""Tests for coerce_list, coerce_dict, strict_search_tags, and parse_str_list utility functions.
|
|
|
|
These must fail until the helpers are implemented in utils.py.
|
|
"""
|
|
|
|
from basic_memory.utils import coerce_dict, coerce_list, parse_str_list, strict_search_tags
|
|
|
|
|
|
class TestCoerceList:
|
|
"""Tests for coerce_list."""
|
|
|
|
def test_none_passthrough(self):
|
|
assert coerce_list(None) is None
|
|
|
|
def test_native_list_passthrough(self):
|
|
assert coerce_list(["a", "b"]) == ["a", "b"]
|
|
|
|
def test_json_array_string(self):
|
|
assert coerce_list('["entity", "observation"]') == ["entity", "observation"]
|
|
|
|
def test_single_string_wrapped(self):
|
|
assert coerce_list("entity") == ["entity"]
|
|
|
|
def test_non_json_string_wrapped(self):
|
|
assert coerce_list("not-json") == ["not-json"]
|
|
|
|
def test_json_object_string_wrapped(self):
|
|
"""A JSON object string is not a list, so wrap it."""
|
|
assert coerce_list('{"key": "val"}') == ['{"key": "val"}']
|
|
|
|
def test_int_passthrough(self):
|
|
"""Non-string, non-None values pass through unchanged."""
|
|
assert coerce_list(42) == 42
|
|
|
|
|
|
class TestStrictSearchTags:
|
|
"""Tests for strict_search_tags (the search_notes tags boundary coercer)."""
|
|
|
|
def test_none_parses_to_empty_list(self):
|
|
assert strict_search_tags(None) == []
|
|
|
|
def test_comma_string_splits(self):
|
|
assert strict_search_tags("a,b") == ["a", "b"]
|
|
|
|
def test_list_with_comma_element_splits(self):
|
|
assert strict_search_tags(["alpha,beta"]) == ["alpha", "beta"]
|
|
|
|
def test_plain_list_passthrough(self):
|
|
assert strict_search_tags(["a", "b"]) == ["a", "b"]
|
|
|
|
def test_json_array_string(self):
|
|
assert strict_search_tags('["a", "b"]') == ["a", "b"]
|
|
|
|
def test_int_passthrough_for_pydantic_rejection(self):
|
|
"""Unsupported types pass through unchanged so Pydantic rejects them."""
|
|
assert strict_search_tags(42) == 42
|
|
|
|
def test_dict_passthrough_for_pydantic_rejection(self):
|
|
value = {"a": 1}
|
|
assert strict_search_tags(value) is value
|
|
|
|
def test_int_list_passthrough_for_pydantic_rejection(self):
|
|
"""Lists with non-string elements pass through unchanged so Pydantic rejects them."""
|
|
value = [42]
|
|
assert strict_search_tags(value) is value
|
|
|
|
def test_dict_list_passthrough_for_pydantic_rejection(self):
|
|
value = [{"a": 1}]
|
|
assert strict_search_tags(value) is value
|
|
|
|
def test_mixed_list_passthrough_for_pydantic_rejection(self):
|
|
"""One bad element poisons the whole list — no partial stringification."""
|
|
value = ["ok", 42]
|
|
assert strict_search_tags(value) is value
|
|
|
|
def test_json_array_string_with_int_passthrough_for_pydantic_rejection(self):
|
|
"""A JSON-array string with non-string elements must not be stringified."""
|
|
value = "[42]"
|
|
assert strict_search_tags(value) is value
|
|
|
|
def test_json_array_string_with_dict_passthrough_for_pydantic_rejection(self):
|
|
value = '[{"a": 1}]'
|
|
assert strict_search_tags(value) is value
|
|
|
|
def test_json_array_string_mixed_passthrough_for_pydantic_rejection(self):
|
|
"""One bad element poisons the whole JSON-array string — no partial parse."""
|
|
value = '["ok", 42]'
|
|
assert strict_search_tags(value) is value
|
|
|
|
def test_json_array_string_all_strings_still_parses(self):
|
|
assert strict_search_tags('["a","b"]') == ["a", "b"]
|
|
|
|
|
|
class TestCoerceDict:
|
|
"""Tests for coerce_dict."""
|
|
|
|
def test_none_passthrough(self):
|
|
assert coerce_dict(None) is None
|
|
|
|
def test_native_dict_passthrough(self):
|
|
assert coerce_dict({"k": "v"}) == {"k": "v"}
|
|
|
|
def test_json_object_string(self):
|
|
assert coerce_dict('{"status": "draft"}') == {"status": "draft"}
|
|
|
|
def test_non_json_string_passthrough(self):
|
|
"""Non-parseable strings pass through (Pydantic will reject them)."""
|
|
assert coerce_dict("not-json") == "not-json"
|
|
|
|
def test_json_array_string_passthrough(self):
|
|
"""A JSON array string is not a dict, so pass through."""
|
|
assert coerce_dict('["a", "b"]') == '["a", "b"]'
|
|
|
|
def test_int_passthrough(self):
|
|
assert coerce_dict(42) == 42
|
|
|
|
|
|
class TestParseStrList:
|
|
"""Tests for parse_str_list — the comma-split coercer for note_types/entity_types/categories."""
|
|
|
|
# --- None input ---
|
|
|
|
def test_none_returns_empty_list(self):
|
|
assert parse_str_list(None) == []
|
|
|
|
# --- Single string inputs ---
|
|
|
|
def test_single_string_wraps_as_one_element(self):
|
|
assert parse_str_list("note") == ["note"]
|
|
|
|
def test_comma_string_splits(self):
|
|
"""The primary motivation for this function: "note,task" → ["note", "task"]."""
|
|
assert parse_str_list("note,task") == ["note", "task"]
|
|
|
|
def test_comma_string_with_spaces_strips(self):
|
|
assert parse_str_list("note, task, person") == ["note", "task", "person"]
|
|
|
|
# --- JSON array string inputs (MCP clients sometimes serialize lists as strings) ---
|
|
|
|
def test_json_array_string(self):
|
|
assert parse_str_list('["note", "task"]') == ["note", "task"]
|
|
|
|
def test_json_array_string_single_element(self):
|
|
assert parse_str_list('["note"]') == ["note"]
|
|
|
|
def test_json_array_string_with_comma_elements(self):
|
|
"""JSON array where an element is itself a comma-string — flatten it."""
|
|
assert parse_str_list('["note,task"]') == ["note", "task"]
|
|
|
|
# --- List inputs ---
|
|
|
|
def test_plain_list_passthrough(self):
|
|
assert parse_str_list(["note", "task"]) == ["note", "task"]
|
|
|
|
def test_list_with_comma_element_splits(self):
|
|
"""A list containing a comma-string is flattened."""
|
|
assert parse_str_list(["note,task"]) == ["note", "task"]
|
|
|
|
def test_list_with_multiple_comma_elements(self):
|
|
assert parse_str_list(["note,task", "person"]) == ["note", "task", "person"]
|
|
|
|
# --- No '#' stripping (unlike parse_tags) ---
|
|
|
|
def test_hash_prefix_preserved(self):
|
|
"""parse_str_list must NOT strip '#' — these are type identifiers, not hashtags."""
|
|
assert parse_str_list("#type") == ["#type"]
|
|
|
|
def test_hash_prefix_in_comma_string_preserved(self):
|
|
assert parse_str_list("#type,#other") == ["#type", "#other"]
|
|
|
|
# --- Non-str/list/None pass through for Pydantic rejection ---
|
|
|
|
def test_int_passthrough_for_pydantic_rejection(self):
|
|
assert parse_str_list(42) == 42 # type: ignore[arg-type]
|
|
|
|
def test_dict_passthrough_for_pydantic_rejection(self):
|
|
value = {"a": 1}
|
|
assert parse_str_list(value) is value # type: ignore[arg-type]
|
|
|
|
# --- Non-string list elements pass through unchanged (Codex review fix) ---
|
|
|
|
def test_int_list_passthrough_for_pydantic_rejection(self):
|
|
"""Lists with non-string elements must not be stringified ([42] → ['42'])."""
|
|
value = [42]
|
|
assert parse_str_list(value) is value # type: ignore[arg-type]
|
|
|
|
def test_mixed_list_passthrough_for_pydantic_rejection(self):
|
|
"""One non-string element poisons the whole list — no partial coercion."""
|
|
value = ["note", 42]
|
|
assert parse_str_list(value) is value # type: ignore[arg-type]
|
|
|
|
def test_dict_list_passthrough_for_pydantic_rejection(self):
|
|
value = [{"a": 1}]
|
|
assert parse_str_list(value) is value # type: ignore[arg-type]
|