Files
Drew Cain 0811c48252 fix(mcp): normalize note_types/entity_types/categories on direct call path and reject non-string list elements
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>
2026-06-11 14:38:24 -05:00

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]