This commit is contained in:
@@ -12,16 +12,18 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from cowork_local.mcp_servers.project_context.foundation import (
|
||||
IdentityContext,
|
||||
ProjectContextRuntime,
|
||||
ProviderError,
|
||||
)
|
||||
from cowork_local.mcp_servers.project_context.providers.issue import (
|
||||
EnvironmentTargetResolver,
|
||||
GiteaIssueProvider,
|
||||
ServiceAccountCredentialResolver,
|
||||
UnconfiguredIssueProvider,
|
||||
_GiteaRepoTarget,
|
||||
build_provider,
|
||||
)
|
||||
from cowork_local.mcp_servers.project_context.runtime import ProjectProviderResolver
|
||||
from cowork_local.mcp_servers.project_context.server import dispatch
|
||||
@@ -95,7 +97,6 @@ def _target(**overrides: Any) -> _GiteaRepoTarget:
|
||||
base_url="http://example.test",
|
||||
owner="gitea-admin",
|
||||
repo="cowork-local",
|
||||
token=FAKE_TOKEN,
|
||||
project_id="cowork-local",
|
||||
)
|
||||
base.update(overrides)
|
||||
@@ -134,7 +135,7 @@ def test_happy_path_returns_full_schema_with_openable_source(
|
||||
) -> None:
|
||||
transport = _FakeTransport([_FakeResponse(200, _issue_payload())])
|
||||
monkeypatch.setattr(requests, "get", transport)
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, policy, resolver = _runtime(identity, provider)
|
||||
|
||||
result = dispatch(
|
||||
@@ -163,11 +164,40 @@ def test_happy_path_returns_full_schema_with_openable_source(
|
||||
assert transport.calls[0]["headers"] == {"Authorization": f"token {FAKE_TOKEN}"}
|
||||
|
||||
|
||||
def test_happy_path_uses_real_project_provider_resolver(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("GITEA_BASE_URL", "http://example.test")
|
||||
monkeypatch.setenv("GITEA_TOKEN", FAKE_TOKEN)
|
||||
monkeypatch.setenv(
|
||||
"PROJECT_CONTEXT_REPO_MAP",
|
||||
'{"cowork-local": "wrong/legacy", '
|
||||
'"fsg/internal/cowork-local": "gitea-admin/cowork-local"}',
|
||||
)
|
||||
transport = _FakeTransport([_FakeResponse(200, _issue_payload())])
|
||||
monkeypatch.setattr(requests, "get", transport)
|
||||
app = ProjectContextRuntime(
|
||||
identity=identity,
|
||||
policy=RecordingPolicy(allowed=True),
|
||||
credential_resolver=ProjectProviderResolver(),
|
||||
)
|
||||
|
||||
result = dispatch(
|
||||
"get_project_issue_context",
|
||||
{"project_id": "cowork-local", "issue_key": "1"},
|
||||
app,
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.payload["title"] == "MCP pilot"
|
||||
assert transport.calls[0]["headers"] == {"Authorization": f"token {FAKE_TOKEN}"}
|
||||
|
||||
|
||||
def test_source_fields_are_all_present_and_well_formed(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, _issue_payload())]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch(
|
||||
@@ -224,7 +254,7 @@ def test_permission_decision_lives_outside_the_tool(
|
||||
outcome, proving `tools/issue_context.py` contains no permission logic
|
||||
of its own."""
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, _issue_payload())]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
arguments = {"project_id": "cowork-local", "issue_key": "1"}
|
||||
|
||||
allowed_app, _, _ = _runtime(identity, provider, allowed=True)
|
||||
@@ -245,7 +275,7 @@ def test_not_found_issue_maps_to_not_found(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(404)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch(
|
||||
@@ -264,7 +294,7 @@ def test_provider_raises_provider_error_directly_for_not_found(
|
||||
dispatch): the raised exception must carry the right `.code`/`.retryable`
|
||||
for the runtime to map correctly."""
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(404)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
provider.get_issue_context(
|
||||
@@ -279,7 +309,7 @@ def test_upstream_timeout_maps_to_upstream_timeout(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([requests.exceptions.Timeout("slow")]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
|
||||
@@ -298,7 +328,7 @@ def test_upstream_status_codes_map_to_distinct_error_codes(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, status_code: int, expected_code: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(status_code)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
|
||||
@@ -311,7 +341,7 @@ def test_malformed_gitea_response_maps_to_upstream_error(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, json_body="__missing__")]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
|
||||
@@ -341,7 +371,7 @@ def test_invalid_issue_key_format_rejected_before_network_call(
|
||||
) -> None:
|
||||
transport = _FakeTransport([]) # empty queue: a real call would raise IndexError
|
||||
monkeypatch.setattr(requests, "get", transport)
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch(
|
||||
@@ -358,7 +388,7 @@ def test_invalid_cursor_rejected_before_network_call(
|
||||
) -> None:
|
||||
transport = _FakeTransport([])
|
||||
monkeypatch.setattr(requests, "get", transport)
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch(
|
||||
@@ -410,6 +440,41 @@ def test_project_without_repo_mapping_returns_unavailable(
|
||||
assert result.payload["error"]["code"] == "UNAVAILABLE"
|
||||
|
||||
|
||||
def test_target_resolver_rejects_project_only_mapping(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("GITEA_BASE_URL", "http://example.test")
|
||||
monkeypatch.setenv("PROJECT_CONTEXT_REPO_MAP", '{"cowork-local": "gitea-admin/cowork-local"}')
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
EnvironmentTargetResolver().resolve(identity)
|
||||
|
||||
assert exc_info.value.code == "UNAVAILABLE"
|
||||
|
||||
|
||||
def test_target_and_credential_resolution_are_separate(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("GITEA_BASE_URL", "http://example.test")
|
||||
monkeypatch.setenv("GITEA_TOKEN", FAKE_TOKEN)
|
||||
monkeypatch.setenv(
|
||||
"PROJECT_CONTEXT_REPO_MAP",
|
||||
'{"fsg/internal/cowork-local": "gitea-admin/cowork-local"}',
|
||||
)
|
||||
|
||||
target = EnvironmentTargetResolver().resolve(identity)
|
||||
credential = ServiceAccountCredentialResolver().resolve(identity, target)
|
||||
provider = build_provider(
|
||||
identity,
|
||||
target_resolver=EnvironmentTargetResolver(),
|
||||
credential_resolver=ServiceAccountCredentialResolver(),
|
||||
)
|
||||
|
||||
assert not hasattr(target, "token")
|
||||
assert credential == FAKE_TOKEN
|
||||
assert isinstance(provider, GiteaIssueProvider)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_map",
|
||||
[
|
||||
@@ -448,7 +513,7 @@ def test_truncation_and_cursor_paginate_related_items(
|
||||
payload = _issue_payload(body=f"See also {mentions}.")
|
||||
transport = _FakeTransport([_FakeResponse(200, payload), _FakeResponse(200, payload)])
|
||||
monkeypatch.setattr(requests, "get", transport)
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
first = dispatch(
|
||||
@@ -482,7 +547,7 @@ def test_full_detail_uses_a_larger_related_page_than_standard(
|
||||
mentions = " ".join(f"#{n}" for n in range(2, 32)) # 30 distinct related items
|
||||
payload = _issue_payload(body=f"See also {mentions}.")
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch(
|
||||
@@ -507,7 +572,7 @@ def test_url_fragment_is_not_mistaken_for_a_related_issue(
|
||||
body = "See http://example.test/gitea-admin/cowork-local/wiki/guide#42 and also #7 directly."
|
||||
payload = _issue_payload(body=body)
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
|
||||
@@ -531,7 +596,7 @@ def test_related_excludes_number_that_is_only_a_markdown_link_label(
|
||||
)
|
||||
payload = _issue_payload(body=body)
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
|
||||
@@ -556,7 +621,7 @@ def test_acceptance_criteria_is_scoped_to_its_own_heading_not_definition_of_done
|
||||
)
|
||||
payload = _issue_payload(body=body)
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
|
||||
@@ -567,6 +632,33 @@ def test_acceptance_criteria_is_scoped_to_its_own_heading_not_definition_of_done
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("heading", ["Tiêu chí hoàn thành", "Tiêu chí chấp nhận"])
|
||||
def test_acceptance_criteria_supports_vietnamese_headings(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, heading: str,
|
||||
) -> None:
|
||||
body = (
|
||||
f"## {heading}\n\n"
|
||||
"- [ ] Điều kiện đúng.\n\n"
|
||||
"## Definition of Done\n\n"
|
||||
"- [ ] Checklist không liên quan.\n"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
requests,
|
||||
"get",
|
||||
_FakeTransport([_FakeResponse(200, _issue_payload(body=body))]),
|
||||
)
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch(
|
||||
"get_project_issue_context",
|
||||
{"project_id": "cowork-local", "issue_key": "1"},
|
||||
app,
|
||||
)
|
||||
|
||||
assert result.payload["acceptance_criteria"] == ["Điều kiện đúng."]
|
||||
|
||||
|
||||
def test_acceptance_criteria_falls_back_to_whole_body_without_a_heading(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -576,7 +668,7 @@ def test_acceptance_criteria_falls_back_to_whole_body_without_a_heading(
|
||||
body = "Ad-hoc issue, no headings.\n\n- [ ] Just do the thing.\n"
|
||||
payload = _issue_payload(body=body)
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
|
||||
@@ -584,13 +676,34 @@ def test_acceptance_criteria_falls_back_to_whole_body_without_a_heading(
|
||||
assert result.payload["acceptance_criteria"] == ["Just do the thing."]
|
||||
|
||||
|
||||
def test_acceptance_criteria_does_not_scan_unrelated_sections(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
body = "# Definition of Done\n\n- [ ] Checklist không phải tiêu chí chấp nhận.\n"
|
||||
monkeypatch.setattr(
|
||||
requests,
|
||||
"get",
|
||||
_FakeTransport([_FakeResponse(200, _issue_payload(body=body))]),
|
||||
)
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch(
|
||||
"get_project_issue_context",
|
||||
{"project_id": "cowork-local", "issue_key": "1"},
|
||||
app,
|
||||
)
|
||||
|
||||
assert result.payload["acceptance_criteria"] == []
|
||||
|
||||
|
||||
def test_summary_detail_omits_related_and_shortens_description(
|
||||
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
long_paragraph = "First paragraph. " * 40 # > 280 chars
|
||||
payload = _issue_payload(body=f"{long_paragraph}\n\nSecond paragraph mentions #2.")
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch(
|
||||
@@ -618,7 +731,7 @@ def test_unexpected_transport_error_does_not_leak_credential_or_raw_exception(
|
||||
f"connect failed for token={FAKE_TOKEN} at internal-host:5432"
|
||||
)
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([leaking_exception]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
|
||||
@@ -636,7 +749,7 @@ def test_not_found_message_does_not_distinguish_missing_from_inaccessible(
|
||||
"""Security requirement: a denial/miss must not reveal whether the
|
||||
underlying resource exists — the safe_message must stay generic."""
|
||||
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(404)]))
|
||||
provider = GiteaIssueProvider(_target())
|
||||
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
|
||||
app, _, _ = _runtime(identity, provider)
|
||||
|
||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
|
||||
|
||||
Reference in New Issue
Block a user