Add a modified_after/modified_before date-range filter to semantic search, honoured on both the MCP tool path (BM25HybridSearchAlgorithm) and the dense-only visualization/API path (SemanticSearchAlgorithm) through one shared contract. - Promote modified_after/modified_before to explicit keyword params on the SearchAlgorithm ABC and both concrete algorithms; factor the shared placeholder+ownership+doc_type+date filter into access_filter.build_base_filter_conditions so new filters land in one place. - nc_semantic_search: accept RFC 3339 / ISO 8601 (or Unix seconds) bounds via utils.validation.parse_modified_timestamp; Annotated/Field constraints on the numeric args; explicit McpError guard for after > before. Thread the parsed bounds through the cross-app and per-doc_type dispatch. - /api/v1 search endpoints + viz route parse the same formats and 400 on bad or inverted ranges. - Add a modified_at INTEGER payload index to _PAYLOAD_INDEX_FIELDS; the idempotent _ensure_payload_indexes() startup path migrates existing collections with no content re-index. - Update ADR-027 to resolve the review feedback (validation placement, shared algorithm contract, deferral of nc_semantic_search_answer, payload index, RFC-3339-at-the-boundary rationale). Add unit tests. Refs ADR-027. Deck #177. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
3.7 KiB
Python
119 lines
3.7 KiB
Python
"""Unit tests for shared boundary validators."""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.utils.validation import (
|
|
is_valid_nextcloud_doc_id,
|
|
parse_modified_timestamp,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
"1",
|
|
"42",
|
|
"1234567890",
|
|
"9999999999999999999",
|
|
],
|
|
)
|
|
def test_accepts_positive_ascii_integers(value):
|
|
"""Any positive ASCII integer (no leading zero) is a valid doc_id."""
|
|
assert is_valid_nextcloud_doc_id(value) is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.parametrize(
|
|
"value,reason",
|
|
[
|
|
("", "empty string"),
|
|
("0", "MySQL AUTO_INCREMENT starts at 1"),
|
|
("01", "leading zero"),
|
|
("00", "leading zeros"),
|
|
("-1", "negative"),
|
|
("+1", "explicit sign"),
|
|
("1.0", "float-like"),
|
|
(" 1", "leading whitespace"),
|
|
("1 ", "trailing whitespace"),
|
|
("1\n", "trailing newline"),
|
|
("abc", "alphabetic"),
|
|
("1a", "trailing letter"),
|
|
("a1", "leading letter"),
|
|
# Unicode digit classes that pass str.isdigit() but are not ASCII.
|
|
# `²` (U+00B2) is a superscript and would slip past the old guard.
|
|
("²", "Unicode superscript-2"),
|
|
# `٢` (U+0662) Arabic-Indic digit two — passes both isdigit() and
|
|
# isdecimal(), so only an explicit ASCII regex catches it.
|
|
("٢", "Arabic-Indic digit two"),
|
|
# `१` (U+0967) Devanagari digit one — same story.
|
|
("१", "Devanagari digit one"),
|
|
# Mixed ASCII + Unicode digits.
|
|
("1٢", "mixed ASCII + Arabic-Indic"),
|
|
],
|
|
)
|
|
def test_rejects_invalid_doc_ids(value, reason):
|
|
"""Reject empty/zero/leading-zero/non-ASCII/non-digit inputs."""
|
|
assert is_valid_nextcloud_doc_id(value) is False, f"should reject: {reason}"
|
|
|
|
|
|
# parse_modified_timestamp (ADR-027) ---------------------------------------
|
|
|
|
_JAN_2026_UTC = int(datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp())
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.parametrize(
|
|
"value,expected",
|
|
[
|
|
(None, None),
|
|
("", None),
|
|
(" ", None),
|
|
# RFC 3339 / ISO 8601
|
|
("2026-01-01T00:00:00Z", _JAN_2026_UTC),
|
|
("2026-01-01T00:00:00+00:00", _JAN_2026_UTC),
|
|
# +02:00 offset is two hours earlier in UTC
|
|
("2026-01-01T02:00:00+02:00", _JAN_2026_UTC),
|
|
# Naive datetime is assumed UTC
|
|
("2026-01-01T00:00:00", _JAN_2026_UTC),
|
|
# Date-only ISO form
|
|
("2026-01-01", _JAN_2026_UTC),
|
|
# Bare Unix seconds (string and int) pass through
|
|
(str(_JAN_2026_UTC), _JAN_2026_UTC),
|
|
(_JAN_2026_UTC, _JAN_2026_UTC),
|
|
(0, 0),
|
|
(1767225600.9, 1767225600), # float truncates
|
|
],
|
|
)
|
|
def test_parse_modified_timestamp_accepts(value, expected):
|
|
"""RFC 3339 strings, Unix seconds, and None normalize to int seconds (UTC)."""
|
|
assert parse_modified_timestamp(value) == expected
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.parametrize(
|
|
"value,reason",
|
|
[
|
|
("not-a-date", "unparseable string"),
|
|
("2026-13-01T00:00:00Z", "invalid month"),
|
|
(-1, "negative int"),
|
|
(-5.0, "negative float"),
|
|
(True, "bool is not a timestamp"),
|
|
(False, "bool is not a timestamp"),
|
|
([], "wrong type"),
|
|
],
|
|
)
|
|
def test_parse_modified_timestamp_rejects(value, reason):
|
|
"""Bad formats / negatives / bools raise ValueError (→ McpError or HTTP 400)."""
|
|
with pytest.raises(ValueError):
|
|
parse_modified_timestamp(value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_modified_timestamp_error_names_param():
|
|
"""The param_name is surfaced in the error for caller-friendly messages."""
|
|
with pytest.raises(ValueError, match="modified_after"):
|
|
parse_modified_timestamp("nope", param_name="modified_after")
|