m365-roadmap-mcp 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/PKG-INFO +3 -1
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/README.md +2 -0
- m365_roadmap_mcp-0.3.0/icon.png +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/pyproject.toml +1 -1
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/src/m365_roadmap_mcp/feeds/m365_api.py +17 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/src/m365_roadmap_mcp/models/feature.py +16 -1
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/src/m365_roadmap_mcp/server.py +9 -2
- m365_roadmap_mcp-0.3.0/src/m365_roadmap_mcp/tools/cloud.py +70 -0
- m365_roadmap_mcp-0.3.0/src/m365_roadmap_mcp/tools/details.py +35 -0
- m365_roadmap_mcp-0.3.0/src/m365_roadmap_mcp/tools/recent.py +50 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/src/m365_roadmap_mcp/tools/search.py +72 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/tests/test_feeds.py +26 -0
- m365_roadmap_mcp-0.3.0/tests/test_tools.py +589 -0
- m365_roadmap_mcp-0.2.1/tests/test_tools.py +0 -244
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/.github/workflows/publish.yml +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/.gitignore +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/LICENSE +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/server.json +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/src/m365_roadmap_mcp/__init__.py +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/src/m365_roadmap_mcp/__main__.py +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/src/m365_roadmap_mcp/feeds/__init__.py +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/src/m365_roadmap_mcp/models/__init__.py +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/src/m365_roadmap_mcp/tools/__init__.py +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/tests/__init__.py +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/tests/api_snapshot.json +0 -0
- {m365_roadmap_mcp-0.2.1 → m365_roadmap_mcp-0.3.0}/tests/rss_feed_snapshot.xml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: m365-roadmap-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: MCP server for querying the Microsoft 365 Roadmap
|
|
5
5
|
Project-URL: Homepage, https://github.com/jonnybottles/M365-roadmap-mcp-server
|
|
6
6
|
Project-URL: Repository, https://github.com/jonnybottles/M365-roadmap-mcp-server
|
|
@@ -24,6 +24,8 @@ Requires-Dist: pytest; extra == 'dev'
|
|
|
24
24
|
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
|
|
27
|
+
### Disclaimer: This is an independent, self-built project and is not an official Microsoft tool or service.
|
|
28
|
+
|
|
27
29
|
# M365 Roadmap MCP Server
|
|
28
30
|
|
|
29
31
|
<!-- mcp-name: io.github.jonnybottles/m365-roadmap -->
|
|
Binary file
|
|
@@ -55,6 +55,20 @@ def _parse_item(item: dict) -> RoadmapFeature | None:
|
|
|
55
55
|
if "tagName" in c
|
|
56
56
|
]
|
|
57
57
|
|
|
58
|
+
# Extract release phases from tagsContainer
|
|
59
|
+
release_phases = [
|
|
60
|
+
r["tagName"]
|
|
61
|
+
for r in tags_container.get("releasePhase", [])
|
|
62
|
+
if "tagName" in r
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# Extract platforms from tagsContainer
|
|
66
|
+
platforms = [
|
|
67
|
+
p["tagName"]
|
|
68
|
+
for p in tags_container.get("platforms", [])
|
|
69
|
+
if "tagName" in p
|
|
70
|
+
]
|
|
71
|
+
|
|
58
72
|
return RoadmapFeature(
|
|
59
73
|
id=str(item.get("id", "")),
|
|
60
74
|
title=item.get("title", ""),
|
|
@@ -62,7 +76,10 @@ def _parse_item(item: dict) -> RoadmapFeature | None:
|
|
|
62
76
|
status=item.get("status"),
|
|
63
77
|
tags=products,
|
|
64
78
|
cloud_instances=cloud_instances,
|
|
79
|
+
release_phases=release_phases,
|
|
80
|
+
platforms=platforms,
|
|
65
81
|
public_disclosure_date=item.get("publicDisclosureAvailabilityDate"),
|
|
82
|
+
public_preview_date=item.get("publicPreviewDate"),
|
|
66
83
|
created=item.get("created"),
|
|
67
84
|
modified=item.get("modified"),
|
|
68
85
|
)
|
|
@@ -21,9 +21,21 @@ class RoadmapFeature(BaseModel):
|
|
|
21
21
|
default_factory=list,
|
|
22
22
|
description="Cloud availability (e.g., Worldwide, GCC, GCC High, DoD)",
|
|
23
23
|
)
|
|
24
|
+
release_phases: list[str] = Field(
|
|
25
|
+
default_factory=list,
|
|
26
|
+
description="Release phases (e.g., General Availability, Preview, Targeted Release)",
|
|
27
|
+
)
|
|
28
|
+
platforms: list[str] = Field(
|
|
29
|
+
default_factory=list,
|
|
30
|
+
description="Target platforms (e.g., Web, Desktop, iOS, Android, Mac)",
|
|
31
|
+
)
|
|
24
32
|
public_disclosure_date: str | None = Field(
|
|
25
33
|
default=None,
|
|
26
|
-
description="Estimated
|
|
34
|
+
description="Estimated rollout start date (e.g., December CY2026)",
|
|
35
|
+
)
|
|
36
|
+
public_preview_date: str | None = Field(
|
|
37
|
+
default=None,
|
|
38
|
+
description="Estimated preview availability date (e.g., July 2026)",
|
|
27
39
|
)
|
|
28
40
|
created: str | None = Field(
|
|
29
41
|
default=None,
|
|
@@ -43,7 +55,10 @@ class RoadmapFeature(BaseModel):
|
|
|
43
55
|
"status": self.status,
|
|
44
56
|
"tags": self.tags,
|
|
45
57
|
"cloud_instances": self.cloud_instances,
|
|
58
|
+
"release_phases": self.release_phases,
|
|
59
|
+
"platforms": self.platforms,
|
|
46
60
|
"public_disclosure_date": self.public_disclosure_date,
|
|
61
|
+
"public_preview_date": self.public_preview_date,
|
|
47
62
|
"created": self.created,
|
|
48
63
|
"modified": self.modified,
|
|
49
64
|
}
|
|
@@ -22,13 +22,20 @@ mcp = FastMCP(
|
|
|
22
22
|
"- status: filter by status ('In development', 'Rolling out', 'Launched')\n"
|
|
23
23
|
"- cloud_instance: filter by cloud instance ('GCC', 'GCC High', 'DoD')\n"
|
|
24
24
|
"- feature_id: retrieve a single feature by its roadmap ID\n"
|
|
25
|
-
"- added_within_days: show only features added within N days\n
|
|
25
|
+
"- added_within_days: show only features added within N days\n"
|
|
26
|
+
"- release_phase: filter by release phase ('General Availability', 'Preview')\n"
|
|
27
|
+
"- platform: filter by platform ('Web', 'Desktop', 'iOS', 'Android', 'Mac')\n"
|
|
28
|
+
"- rollout_date: filter by rollout start date (e.g. 'December 2026')\n"
|
|
29
|
+
"- preview_date: filter by preview availability date (e.g. 'July 2026')\n"
|
|
30
|
+
"- modified_within_days: show only features modified within N days\n\n"
|
|
26
31
|
"Tips:\n"
|
|
27
32
|
"- To get feature details, use feature_id with the roadmap ID.\n"
|
|
28
33
|
"- To check cloud availability, use cloud_instance with a feature_id or "
|
|
29
34
|
"product filter. The cloud_instances field in each result shows all "
|
|
30
35
|
"supported instances.\n"
|
|
31
|
-
"- To list recent additions, use added_within_days (e.g. 30 for last month)
|
|
36
|
+
"- To list recent additions, use added_within_days (e.g. 30 for last month).\n"
|
|
37
|
+
"- To filter by release phase or platform, use release_phase or platform filters.\n"
|
|
38
|
+
"- To search by dates, use rollout_date or preview_date with partial date strings."
|
|
32
39
|
),
|
|
33
40
|
)
|
|
34
41
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Tool for checking cloud instance availability of M365 Roadmap features."""
|
|
2
|
+
|
|
3
|
+
from ..feeds.m365_api import fetch_features
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def check_cloud_availability(feature_id: str, instance: str) -> dict:
|
|
7
|
+
"""Check whether a Microsoft 365 Roadmap feature is available for a specific cloud instance.
|
|
8
|
+
|
|
9
|
+
Critical for government and defense clients who need to verify feature availability
|
|
10
|
+
on GCC, GCC High, or DoD cloud instances before planning deployments.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
feature_id: The unique Roadmap ID (e.g., "534606").
|
|
14
|
+
instance: The cloud instance to check (e.g., "GCC", "GCC High", "DoD",
|
|
15
|
+
"Worldwide"). Case-insensitive partial match is used, so "gcc" matches
|
|
16
|
+
"GCC" and "GCC High".
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dictionary with:
|
|
20
|
+
- feature_id: The queried feature ID
|
|
21
|
+
- instance_queried: The cloud instance that was checked
|
|
22
|
+
- found: Whether the feature was found in the roadmap
|
|
23
|
+
- available: Whether the feature is available for the queried instance
|
|
24
|
+
- matched_instances: List of cloud instances that matched the query
|
|
25
|
+
- all_instances: All cloud instances the feature supports
|
|
26
|
+
- status: Feature status (if found)
|
|
27
|
+
- public_disclosure_date: Estimated release date (if found)
|
|
28
|
+
- title: Feature title (if found)
|
|
29
|
+
- error: Error message (if feature not found)
|
|
30
|
+
"""
|
|
31
|
+
features = await fetch_features()
|
|
32
|
+
|
|
33
|
+
# Find the feature by ID
|
|
34
|
+
target = None
|
|
35
|
+
for feature in features:
|
|
36
|
+
if feature.id == feature_id:
|
|
37
|
+
target = feature
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
if target is None:
|
|
41
|
+
return {
|
|
42
|
+
"feature_id": feature_id,
|
|
43
|
+
"instance_queried": instance,
|
|
44
|
+
"found": False,
|
|
45
|
+
"available": False,
|
|
46
|
+
"matched_instances": [],
|
|
47
|
+
"all_instances": [],
|
|
48
|
+
"status": None,
|
|
49
|
+
"public_disclosure_date": None,
|
|
50
|
+
"title": None,
|
|
51
|
+
"error": f"No feature found with ID '{feature_id}'",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Case-insensitive partial match on cloud instances
|
|
55
|
+
instance_lower = instance.lower()
|
|
56
|
+
matched = [
|
|
57
|
+
ci for ci in target.cloud_instances if instance_lower in ci.lower()
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"feature_id": feature_id,
|
|
62
|
+
"instance_queried": instance,
|
|
63
|
+
"found": True,
|
|
64
|
+
"available": len(matched) > 0,
|
|
65
|
+
"matched_instances": matched,
|
|
66
|
+
"all_instances": target.cloud_instances,
|
|
67
|
+
"status": target.status,
|
|
68
|
+
"public_disclosure_date": target.public_disclosure_date,
|
|
69
|
+
"title": target.title,
|
|
70
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tool for retrieving full details of a single M365 Roadmap feature."""
|
|
2
|
+
|
|
3
|
+
from ..feeds.m365_api import fetch_features
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def get_feature_details(feature_id: str) -> dict:
|
|
7
|
+
"""Retrieve full metadata for a specific Microsoft 365 Roadmap feature by its ID.
|
|
8
|
+
|
|
9
|
+
Use this tool when you need complete details about a known roadmap feature,
|
|
10
|
+
including its description, status, product tags, cloud instance availability,
|
|
11
|
+
and release date.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
feature_id: The unique Roadmap ID (e.g., "534606").
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Dictionary with:
|
|
18
|
+
- found: Whether the feature was found
|
|
19
|
+
- feature: Full feature object if found, None otherwise
|
|
20
|
+
- error: Error message if not found
|
|
21
|
+
"""
|
|
22
|
+
features = await fetch_features()
|
|
23
|
+
|
|
24
|
+
for feature in features:
|
|
25
|
+
if feature.id == feature_id:
|
|
26
|
+
return {
|
|
27
|
+
"found": True,
|
|
28
|
+
"feature": feature.to_dict(),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"found": False,
|
|
33
|
+
"feature": None,
|
|
34
|
+
"error": f"No feature found with ID '{feature_id}'",
|
|
35
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tool for listing recently added M365 Roadmap features."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
from ..feeds.m365_api import fetch_features
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def list_recent_additions(days: int = 7) -> dict:
|
|
9
|
+
"""List features recently added to the Microsoft 365 Roadmap.
|
|
10
|
+
|
|
11
|
+
Use this tool to monitor what new features have appeared on the roadmap
|
|
12
|
+
within a given time window. Useful for staying current on Microsoft's
|
|
13
|
+
latest plans and announcements.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
days: Number of days to look back (default: 7, clamped to 1–365).
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dictionary with:
|
|
20
|
+
- total_found: Number of features added within the time window
|
|
21
|
+
- features: List of recently added feature objects
|
|
22
|
+
- days_queried: The actual number of days used (after clamping)
|
|
23
|
+
- cutoff_date: The earliest date included in the results (ISO format)
|
|
24
|
+
"""
|
|
25
|
+
# Clamp days to reasonable bounds
|
|
26
|
+
days = max(1, min(days, 365))
|
|
27
|
+
|
|
28
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
|
29
|
+
features = await fetch_features()
|
|
30
|
+
|
|
31
|
+
recent = []
|
|
32
|
+
for feature in features:
|
|
33
|
+
if not feature.created:
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
created_dt = datetime.fromisoformat(feature.created)
|
|
37
|
+
# Ensure timezone-aware comparison
|
|
38
|
+
if created_dt.tzinfo is None:
|
|
39
|
+
created_dt = created_dt.replace(tzinfo=timezone.utc)
|
|
40
|
+
if created_dt >= cutoff:
|
|
41
|
+
recent.append(feature)
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"total_found": len(recent),
|
|
47
|
+
"features": [f.to_dict() for f in recent],
|
|
48
|
+
"days_queried": days,
|
|
49
|
+
"cutoff_date": cutoff.isoformat(),
|
|
50
|
+
}
|
|
@@ -12,6 +12,11 @@ async def search_roadmap(
|
|
|
12
12
|
cloud_instance: str | None = None,
|
|
13
13
|
feature_id: str | None = None,
|
|
14
14
|
added_within_days: int | None = None,
|
|
15
|
+
release_phase: str | None = None,
|
|
16
|
+
platform: str | None = None,
|
|
17
|
+
rollout_date: str | None = None,
|
|
18
|
+
preview_date: str | None = None,
|
|
19
|
+
modified_within_days: int | None = None,
|
|
15
20
|
limit: int = 10,
|
|
16
21
|
) -> dict:
|
|
17
22
|
"""Search the Microsoft 365 Roadmap for features matching keywords and filters.
|
|
@@ -29,6 +34,11 @@ async def search_roadmap(
|
|
|
29
34
|
- Filter by cloud instance (cloud_instance="GCC High", "DoD", "GCC")
|
|
30
35
|
- Retrieve a specific feature by ID (feature_id="534606")
|
|
31
36
|
- List recently added features (added_within_days=30)
|
|
37
|
+
- Filter by release phase (release_phase="General Availability", "Preview")
|
|
38
|
+
- Filter by platform (platform="Web", "iOS", "Android")
|
|
39
|
+
- Filter by rollout date (rollout_date="December 2026")
|
|
40
|
+
- Filter by preview date (preview_date="July 2026")
|
|
41
|
+
- List recently modified features (modified_within_days=7)
|
|
32
42
|
- Combine any of the above (query="Copilot" + product="Teams" + cloud_instance="GCC")
|
|
33
43
|
|
|
34
44
|
Args:
|
|
@@ -43,6 +53,14 @@ async def search_roadmap(
|
|
|
43
53
|
added_within_days: Optional number of days to look back for recently added
|
|
44
54
|
features (clamped to 1–365). Only features with a created date within
|
|
45
55
|
this window are returned.
|
|
56
|
+
release_phase: Optional release phase filter (case-insensitive partial match).
|
|
57
|
+
platform: Optional platform filter (case-insensitive partial match).
|
|
58
|
+
rollout_date: Optional rollout start date filter (partial string match against
|
|
59
|
+
publicDisclosureAvailabilityDate, e.g. "December 2026").
|
|
60
|
+
preview_date: Optional preview availability date filter (partial string match
|
|
61
|
+
against publicPreviewDate, e.g. "July 2026").
|
|
62
|
+
modified_within_days: Optional number of days to look back for recently modified
|
|
63
|
+
features (clamped to 1-365).
|
|
46
64
|
limit: Maximum number of results to return (default: 10, max: 100).
|
|
47
65
|
Ignored when feature_id is provided.
|
|
48
66
|
|
|
@@ -78,11 +96,21 @@ async def search_roadmap(
|
|
|
78
96
|
added_within_days = max(1, min(added_within_days, 365))
|
|
79
97
|
cutoff = datetime.now(timezone.utc) - timedelta(days=added_within_days)
|
|
80
98
|
|
|
99
|
+
# Compute modified cutoff if requested
|
|
100
|
+
modified_cutoff = None
|
|
101
|
+
if modified_within_days is not None:
|
|
102
|
+
modified_within_days = max(1, min(modified_within_days, 365))
|
|
103
|
+
modified_cutoff = datetime.now(timezone.utc) - timedelta(days=modified_within_days)
|
|
104
|
+
|
|
81
105
|
# Prepare lowercase values for case-insensitive matching
|
|
82
106
|
query_lower = query.lower() if query else None
|
|
83
107
|
product_lower = product.lower() if product else None
|
|
84
108
|
status_lower = status.lower() if status else None
|
|
85
109
|
cloud_lower = cloud_instance.lower() if cloud_instance else None
|
|
110
|
+
release_phase_lower = release_phase.lower() if release_phase else None
|
|
111
|
+
platform_lower = platform.lower() if platform else None
|
|
112
|
+
rollout_date_lower = rollout_date.lower() if rollout_date else None
|
|
113
|
+
preview_date_lower = preview_date.lower() if preview_date else None
|
|
86
114
|
|
|
87
115
|
# Apply all filters
|
|
88
116
|
matched = []
|
|
@@ -102,6 +130,26 @@ async def search_roadmap(
|
|
|
102
130
|
if not any(cloud_lower in ci.lower() for ci in feature.cloud_instances):
|
|
103
131
|
continue
|
|
104
132
|
|
|
133
|
+
# Release phase filter (partial match)
|
|
134
|
+
if release_phase_lower:
|
|
135
|
+
if not any(release_phase_lower in rp.lower() for rp in feature.release_phases):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Platform filter (partial match)
|
|
139
|
+
if platform_lower:
|
|
140
|
+
if not any(platform_lower in p.lower() for p in feature.platforms):
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Rollout date filter (partial match against publicDisclosureAvailabilityDate)
|
|
144
|
+
if rollout_date_lower:
|
|
145
|
+
if not feature.public_disclosure_date or rollout_date_lower not in feature.public_disclosure_date.lower():
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Preview date filter (partial match against publicPreviewDate)
|
|
149
|
+
if preview_date_lower:
|
|
150
|
+
if not feature.public_preview_date or preview_date_lower not in feature.public_preview_date.lower():
|
|
151
|
+
continue
|
|
152
|
+
|
|
105
153
|
# Keyword search (title + description)
|
|
106
154
|
if query_lower:
|
|
107
155
|
if (
|
|
@@ -123,6 +171,19 @@ async def search_roadmap(
|
|
|
123
171
|
except (ValueError, TypeError):
|
|
124
172
|
continue
|
|
125
173
|
|
|
174
|
+
# Recency filter (modified_within_days)
|
|
175
|
+
if modified_cutoff is not None:
|
|
176
|
+
if not feature.modified:
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
modified_dt = datetime.fromisoformat(feature.modified)
|
|
180
|
+
if modified_dt.tzinfo is None:
|
|
181
|
+
modified_dt = modified_dt.replace(tzinfo=timezone.utc)
|
|
182
|
+
if modified_dt < modified_cutoff:
|
|
183
|
+
continue
|
|
184
|
+
except (ValueError, TypeError):
|
|
185
|
+
continue
|
|
186
|
+
|
|
126
187
|
matched.append(feature)
|
|
127
188
|
|
|
128
189
|
# Build filters summary
|
|
@@ -138,6 +199,17 @@ async def search_roadmap(
|
|
|
138
199
|
if added_within_days is not None:
|
|
139
200
|
filters_applied["added_within_days"] = added_within_days
|
|
140
201
|
filters_applied["cutoff_date"] = cutoff.isoformat()
|
|
202
|
+
if modified_within_days is not None:
|
|
203
|
+
filters_applied["modified_within_days"] = modified_within_days
|
|
204
|
+
filters_applied["modified_cutoff_date"] = modified_cutoff.isoformat()
|
|
205
|
+
if release_phase:
|
|
206
|
+
filters_applied["release_phase"] = release_phase
|
|
207
|
+
if platform:
|
|
208
|
+
filters_applied["platform"] = platform
|
|
209
|
+
if rollout_date:
|
|
210
|
+
filters_applied["rollout_date"] = rollout_date
|
|
211
|
+
if preview_date:
|
|
212
|
+
filters_applied["preview_date"] = preview_date
|
|
141
213
|
if not filters_applied:
|
|
142
214
|
filters_applied["note"] = "No filters applied, returning most recent features"
|
|
143
215
|
|
|
@@ -36,3 +36,29 @@ async def test_features_sorted_by_created_date():
|
|
|
36
36
|
# Both should have created dates; newest first
|
|
37
37
|
if features[i].created and features[i + 1].created:
|
|
38
38
|
assert features[i].created >= features[i + 1].created
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_feature_has_new_fields():
|
|
43
|
+
"""Test that features have the newly added fields."""
|
|
44
|
+
features = await fetch_features()
|
|
45
|
+
|
|
46
|
+
if features:
|
|
47
|
+
feature = features[0]
|
|
48
|
+
assert hasattr(feature, "release_phases")
|
|
49
|
+
assert hasattr(feature, "platforms")
|
|
50
|
+
assert hasattr(feature, "public_preview_date")
|
|
51
|
+
assert isinstance(feature.release_phases, list)
|
|
52
|
+
assert isinstance(feature.platforms, list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_feature_with_populated_fields():
|
|
57
|
+
"""Test that at least some features have populated release_phases and platforms."""
|
|
58
|
+
features = await fetch_features()
|
|
59
|
+
|
|
60
|
+
has_release_phase = any(len(f.release_phases) > 0 for f in features)
|
|
61
|
+
has_platform = any(len(f.platforms) > 0 for f in features)
|
|
62
|
+
|
|
63
|
+
assert has_release_phase, "No features have release_phases populated"
|
|
64
|
+
assert has_platform, "No features have platforms populated"
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""Tests for MCP tools."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
# search_roadmap
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
async def test_search_no_filters_returns_recent():
|
|
13
|
+
"""When called with no filters, returns the most recent features."""
|
|
14
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
15
|
+
|
|
16
|
+
result = await search_roadmap(limit=5)
|
|
17
|
+
|
|
18
|
+
assert isinstance(result, dict)
|
|
19
|
+
assert "total_found" in result
|
|
20
|
+
assert "features" in result
|
|
21
|
+
assert "filters_applied" in result
|
|
22
|
+
assert len(result["features"]) <= 5
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_search_by_keyword():
|
|
27
|
+
"""Keyword filter matches title or description."""
|
|
28
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
29
|
+
|
|
30
|
+
result = await search_roadmap(query="Microsoft", limit=5)
|
|
31
|
+
|
|
32
|
+
assert isinstance(result["features"], list)
|
|
33
|
+
for feature in result["features"]:
|
|
34
|
+
text = (feature["title"] + feature["description"]).lower()
|
|
35
|
+
assert "microsoft" in text
|
|
36
|
+
assert result["filters_applied"].get("query") == "Microsoft"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_search_by_product():
|
|
41
|
+
"""Product filter returns features tagged with the given product."""
|
|
42
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
43
|
+
|
|
44
|
+
result = await search_roadmap(product="Teams", limit=5)
|
|
45
|
+
|
|
46
|
+
assert isinstance(result["features"], list)
|
|
47
|
+
assert len(result["features"]) <= 5
|
|
48
|
+
for feature in result["features"]:
|
|
49
|
+
assert any("teams" in tag.lower() for tag in feature["tags"])
|
|
50
|
+
assert result["filters_applied"].get("product") == "Teams"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_search_by_status():
|
|
55
|
+
"""Status filter returns only matching status."""
|
|
56
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
57
|
+
|
|
58
|
+
result = await search_roadmap(status="In development", limit=5)
|
|
59
|
+
|
|
60
|
+
for feature in result["features"]:
|
|
61
|
+
assert feature["status"].lower() == "in development"
|
|
62
|
+
assert result["filters_applied"].get("status") == "In development"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_search_by_cloud_instance():
|
|
67
|
+
"""Cloud instance filter returns features available for that instance."""
|
|
68
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
69
|
+
|
|
70
|
+
result = await search_roadmap(cloud_instance="GCC", limit=5)
|
|
71
|
+
|
|
72
|
+
assert isinstance(result["features"], list)
|
|
73
|
+
for feature in result["features"]:
|
|
74
|
+
assert any("gcc" in ci.lower() for ci in feature["cloud_instances"])
|
|
75
|
+
assert result["filters_applied"].get("cloud_instance") == "GCC"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.asyncio
|
|
79
|
+
async def test_search_by_feature_id():
|
|
80
|
+
"""Feature ID lookup retrieves a single specific feature."""
|
|
81
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
82
|
+
|
|
83
|
+
# First get a valid ID
|
|
84
|
+
recent = await search_roadmap(limit=1)
|
|
85
|
+
if recent["features"]:
|
|
86
|
+
fid = recent["features"][0]["id"]
|
|
87
|
+
result = await search_roadmap(feature_id=fid)
|
|
88
|
+
|
|
89
|
+
assert result["total_found"] == 1
|
|
90
|
+
assert result["features"][0]["id"] == fid
|
|
91
|
+
assert result["filters_applied"].get("feature_id") == fid
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_search_by_feature_id_not_found():
|
|
96
|
+
"""Feature ID lookup for nonexistent ID returns empty results."""
|
|
97
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
98
|
+
|
|
99
|
+
result = await search_roadmap(feature_id="nonexistent-id-99999")
|
|
100
|
+
|
|
101
|
+
assert result["total_found"] == 0
|
|
102
|
+
assert result["features"] == []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_search_combined_filters():
|
|
107
|
+
"""Multiple filters can be combined."""
|
|
108
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
109
|
+
|
|
110
|
+
result = await search_roadmap(
|
|
111
|
+
query="Microsoft",
|
|
112
|
+
status="In development",
|
|
113
|
+
limit=5,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assert isinstance(result, dict)
|
|
117
|
+
for feature in result["features"]:
|
|
118
|
+
text = (feature["title"] + feature["description"]).lower()
|
|
119
|
+
assert "microsoft" in text
|
|
120
|
+
assert feature["status"].lower() == "in development"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.asyncio
|
|
124
|
+
async def test_search_limit_clamping():
|
|
125
|
+
"""Limit is clamped between 1 and 100."""
|
|
126
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
127
|
+
|
|
128
|
+
result_low = await search_roadmap(limit=0)
|
|
129
|
+
assert len(result_low["features"]) >= 0 # limit clamped to 1
|
|
130
|
+
assert len(result_low["features"]) <= 1
|
|
131
|
+
|
|
132
|
+
result_high = await search_roadmap(limit=999)
|
|
133
|
+
assert len(result_high["features"]) <= 100
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.mark.asyncio
|
|
137
|
+
async def test_search_output_structure():
|
|
138
|
+
"""Output includes all expected keys and correct types."""
|
|
139
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
140
|
+
|
|
141
|
+
result = await search_roadmap(limit=1)
|
|
142
|
+
|
|
143
|
+
assert "total_found" in result
|
|
144
|
+
assert "features" in result
|
|
145
|
+
assert "filters_applied" in result
|
|
146
|
+
assert isinstance(result["total_found"], int)
|
|
147
|
+
assert isinstance(result["features"], list)
|
|
148
|
+
assert isinstance(result["filters_applied"], dict)
|
|
149
|
+
|
|
150
|
+
if result["features"]:
|
|
151
|
+
feature = result["features"][0]
|
|
152
|
+
assert "id" in feature
|
|
153
|
+
assert "title" in feature
|
|
154
|
+
assert "description" in feature
|
|
155
|
+
assert "status" in feature
|
|
156
|
+
assert "tags" in feature
|
|
157
|
+
assert "cloud_instances" in feature
|
|
158
|
+
assert "public_disclosure_date" in feature
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# added_within_days
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_added_within_days_basic():
|
|
168
|
+
"""added_within_days returns features and reports the filter."""
|
|
169
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
170
|
+
|
|
171
|
+
result = await search_roadmap(added_within_days=30)
|
|
172
|
+
|
|
173
|
+
assert isinstance(result, dict)
|
|
174
|
+
assert isinstance(result["features"], list)
|
|
175
|
+
assert result["filters_applied"].get("added_within_days") == 30
|
|
176
|
+
assert "cutoff_date" in result["filters_applied"]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_added_within_days_larger_window_gte_smaller():
|
|
181
|
+
"""A larger time window should return >= features than a smaller one."""
|
|
182
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
183
|
+
|
|
184
|
+
small = await search_roadmap(added_within_days=7, limit=100)
|
|
185
|
+
large = await search_roadmap(added_within_days=90, limit=100)
|
|
186
|
+
|
|
187
|
+
assert large["total_found"] >= small["total_found"]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@pytest.mark.asyncio
|
|
191
|
+
async def test_added_within_days_clamping_low():
|
|
192
|
+
"""Days below 1 is clamped to 1."""
|
|
193
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
194
|
+
|
|
195
|
+
result = await search_roadmap(added_within_days=0)
|
|
196
|
+
|
|
197
|
+
assert result["filters_applied"]["added_within_days"] == 1
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pytest.mark.asyncio
|
|
201
|
+
async def test_added_within_days_clamping_high():
|
|
202
|
+
"""Days above 365 is clamped to 365."""
|
|
203
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
204
|
+
|
|
205
|
+
result = await search_roadmap(added_within_days=9999)
|
|
206
|
+
|
|
207
|
+
assert result["filters_applied"]["added_within_days"] == 365
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@pytest.mark.asyncio
|
|
211
|
+
async def test_added_within_days_features_have_created_date():
|
|
212
|
+
"""All returned features should have a created date when filtering by recency."""
|
|
213
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
214
|
+
|
|
215
|
+
result = await search_roadmap(added_within_days=365, limit=100)
|
|
216
|
+
|
|
217
|
+
for feature in result["features"]:
|
|
218
|
+
assert feature["created"] is not None
|
|
219
|
+
assert feature["created"] != ""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.asyncio
|
|
223
|
+
async def test_added_within_days_combined_with_product():
|
|
224
|
+
"""added_within_days can be combined with other filters."""
|
|
225
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
226
|
+
|
|
227
|
+
result = await search_roadmap(product="Teams", added_within_days=365, limit=5)
|
|
228
|
+
|
|
229
|
+
assert result["filters_applied"].get("product") == "Teams"
|
|
230
|
+
assert result["filters_applied"].get("added_within_days") == 365
|
|
231
|
+
for feature in result["features"]:
|
|
232
|
+
assert any("teams" in tag.lower() for tag in feature["tags"])
|
|
233
|
+
assert feature["created"] is not None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@pytest.mark.asyncio
|
|
237
|
+
async def test_added_within_days_none_means_no_filter():
|
|
238
|
+
"""When added_within_days is None, no recency filter is applied."""
|
|
239
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
240
|
+
|
|
241
|
+
result = await search_roadmap(limit=5)
|
|
242
|
+
|
|
243
|
+
assert "added_within_days" not in result["filters_applied"]
|
|
244
|
+
assert "cutoff_date" not in result["filters_applied"]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# release_phase filter
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@pytest.mark.asyncio
|
|
253
|
+
async def test_search_by_release_phase():
|
|
254
|
+
"""Release phase filter returns features with matching release phase."""
|
|
255
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
256
|
+
|
|
257
|
+
result = await search_roadmap(release_phase="General Availability", limit=10)
|
|
258
|
+
|
|
259
|
+
assert isinstance(result["features"], list)
|
|
260
|
+
for feature in result["features"]:
|
|
261
|
+
assert any("general availability" in rp.lower() for rp in feature["release_phases"])
|
|
262
|
+
assert result["filters_applied"].get("release_phase") == "General Availability"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@pytest.mark.asyncio
|
|
266
|
+
async def test_search_by_release_phase_partial_match():
|
|
267
|
+
"""Release phase filter supports partial matching."""
|
|
268
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
269
|
+
|
|
270
|
+
result = await search_roadmap(release_phase="preview", limit=10)
|
|
271
|
+
|
|
272
|
+
assert isinstance(result["features"], list)
|
|
273
|
+
for feature in result["features"]:
|
|
274
|
+
assert any("preview" in rp.lower() for rp in feature["release_phases"])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@pytest.mark.asyncio
|
|
278
|
+
async def test_search_by_release_phase_case_insensitive():
|
|
279
|
+
"""Release phase filter is case-insensitive."""
|
|
280
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
281
|
+
|
|
282
|
+
result_lower = await search_roadmap(release_phase="preview", limit=10)
|
|
283
|
+
result_upper = await search_roadmap(release_phase="PREVIEW", limit=10)
|
|
284
|
+
|
|
285
|
+
assert result_lower["total_found"] == result_upper["total_found"]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# platform filter
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@pytest.mark.asyncio
|
|
294
|
+
async def test_search_by_platform():
|
|
295
|
+
"""Platform filter returns features with matching platform."""
|
|
296
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
297
|
+
|
|
298
|
+
result = await search_roadmap(platform="Web", limit=10)
|
|
299
|
+
|
|
300
|
+
assert isinstance(result["features"], list)
|
|
301
|
+
for feature in result["features"]:
|
|
302
|
+
assert any("web" in p.lower() for p in feature["platforms"])
|
|
303
|
+
assert result["filters_applied"].get("platform") == "Web"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@pytest.mark.asyncio
|
|
307
|
+
async def test_search_by_platform_multiple():
|
|
308
|
+
"""Platform filter can match different platform values."""
|
|
309
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
310
|
+
|
|
311
|
+
result_web = await search_roadmap(platform="Web", limit=100)
|
|
312
|
+
result_ios = await search_roadmap(platform="iOS", limit=100)
|
|
313
|
+
|
|
314
|
+
# Both should return some results (assuming data exists)
|
|
315
|
+
assert result_web["total_found"] >= 0
|
|
316
|
+
assert result_ios["total_found"] >= 0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@pytest.mark.asyncio
|
|
320
|
+
async def test_search_by_platform_combined_with_product():
|
|
321
|
+
"""Platform filter can be combined with product filter."""
|
|
322
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
323
|
+
|
|
324
|
+
result = await search_roadmap(platform="Web", product="Teams", limit=10)
|
|
325
|
+
|
|
326
|
+
assert isinstance(result["features"], list)
|
|
327
|
+
for feature in result["features"]:
|
|
328
|
+
assert any("web" in p.lower() for p in feature["platforms"])
|
|
329
|
+
assert any("teams" in tag.lower() for tag in feature["tags"])
|
|
330
|
+
assert result["filters_applied"].get("platform") == "Web"
|
|
331
|
+
assert result["filters_applied"].get("product") == "Teams"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# rollout_date filter
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@pytest.mark.asyncio
|
|
340
|
+
async def test_search_by_rollout_date_year():
|
|
341
|
+
"""Rollout date filter matches year in publicDisclosureAvailabilityDate."""
|
|
342
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
343
|
+
|
|
344
|
+
result = await search_roadmap(rollout_date="2026", limit=10)
|
|
345
|
+
|
|
346
|
+
assert isinstance(result["features"], list)
|
|
347
|
+
for feature in result["features"]:
|
|
348
|
+
assert feature["public_disclosure_date"] is not None
|
|
349
|
+
assert "2026" in feature["public_disclosure_date"]
|
|
350
|
+
assert result["filters_applied"].get("rollout_date") == "2026"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@pytest.mark.asyncio
|
|
354
|
+
async def test_search_by_rollout_date_month():
|
|
355
|
+
"""Rollout date filter supports partial month matching."""
|
|
356
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
357
|
+
|
|
358
|
+
result = await search_roadmap(rollout_date="December 2026", limit=10)
|
|
359
|
+
|
|
360
|
+
assert isinstance(result["features"], list)
|
|
361
|
+
for feature in result["features"]:
|
|
362
|
+
assert feature["public_disclosure_date"] is not None
|
|
363
|
+
assert "december" in feature["public_disclosure_date"].lower()
|
|
364
|
+
assert "2026" in feature["public_disclosure_date"]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@pytest.mark.asyncio
|
|
368
|
+
async def test_search_by_rollout_date_filters_nulls():
|
|
369
|
+
"""Rollout date filter excludes features with no publicDisclosureAvailabilityDate."""
|
|
370
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
371
|
+
|
|
372
|
+
result = await search_roadmap(rollout_date="2026", limit=100)
|
|
373
|
+
|
|
374
|
+
for feature in result["features"]:
|
|
375
|
+
assert feature["public_disclosure_date"] is not None
|
|
376
|
+
assert feature["public_disclosure_date"] != ""
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@pytest.mark.asyncio
|
|
380
|
+
async def test_search_by_rollout_date_case_insensitive():
|
|
381
|
+
"""Rollout date filter is case-insensitive."""
|
|
382
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
383
|
+
|
|
384
|
+
result_lower = await search_roadmap(rollout_date="december 2026", limit=10)
|
|
385
|
+
result_upper = await search_roadmap(rollout_date="DECEMBER 2026", limit=10)
|
|
386
|
+
|
|
387
|
+
assert result_lower["total_found"] == result_upper["total_found"]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
# preview_date filter
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@pytest.mark.asyncio
|
|
396
|
+
async def test_search_by_preview_date():
|
|
397
|
+
"""Preview date filter matches publicPreviewDate."""
|
|
398
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
399
|
+
|
|
400
|
+
result = await search_roadmap(preview_date="2026", limit=10)
|
|
401
|
+
|
|
402
|
+
assert isinstance(result["features"], list)
|
|
403
|
+
for feature in result["features"]:
|
|
404
|
+
assert feature["public_preview_date"] is not None
|
|
405
|
+
assert "2026" in feature["public_preview_date"]
|
|
406
|
+
assert result["filters_applied"].get("preview_date") == "2026"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@pytest.mark.asyncio
|
|
410
|
+
async def test_search_by_preview_date_partial():
|
|
411
|
+
"""Preview date filter supports partial matching."""
|
|
412
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
413
|
+
|
|
414
|
+
result = await search_roadmap(preview_date="July", limit=10)
|
|
415
|
+
|
|
416
|
+
assert isinstance(result["features"], list)
|
|
417
|
+
for feature in result["features"]:
|
|
418
|
+
assert feature["public_preview_date"] is not None
|
|
419
|
+
assert "july" in feature["public_preview_date"].lower()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@pytest.mark.asyncio
|
|
423
|
+
async def test_search_by_preview_date_filters_nulls():
|
|
424
|
+
"""Preview date filter excludes features with no publicPreviewDate."""
|
|
425
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
426
|
+
|
|
427
|
+
result = await search_roadmap(preview_date="2026", limit=100)
|
|
428
|
+
|
|
429
|
+
for feature in result["features"]:
|
|
430
|
+
assert feature["public_preview_date"] is not None
|
|
431
|
+
assert feature["public_preview_date"] != ""
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# modified_within_days filter
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@pytest.mark.asyncio
|
|
440
|
+
async def test_modified_within_days_basic():
|
|
441
|
+
"""modified_within_days returns features and reports the filter."""
|
|
442
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
443
|
+
|
|
444
|
+
result = await search_roadmap(modified_within_days=30)
|
|
445
|
+
|
|
446
|
+
assert isinstance(result, dict)
|
|
447
|
+
assert isinstance(result["features"], list)
|
|
448
|
+
assert result["filters_applied"].get("modified_within_days") == 30
|
|
449
|
+
assert "modified_cutoff_date" in result["filters_applied"]
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@pytest.mark.asyncio
|
|
453
|
+
async def test_modified_within_days_larger_window_gte_smaller():
|
|
454
|
+
"""A larger time window should return >= features than a smaller one."""
|
|
455
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
456
|
+
|
|
457
|
+
small = await search_roadmap(modified_within_days=7, limit=100)
|
|
458
|
+
large = await search_roadmap(modified_within_days=90, limit=100)
|
|
459
|
+
|
|
460
|
+
assert large["total_found"] >= small["total_found"]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@pytest.mark.asyncio
|
|
464
|
+
async def test_modified_within_days_clamping_low():
|
|
465
|
+
"""Days below 1 is clamped to 1."""
|
|
466
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
467
|
+
|
|
468
|
+
result = await search_roadmap(modified_within_days=0)
|
|
469
|
+
|
|
470
|
+
assert result["filters_applied"]["modified_within_days"] == 1
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@pytest.mark.asyncio
|
|
474
|
+
async def test_modified_within_days_clamping_high():
|
|
475
|
+
"""Days above 365 is clamped to 365."""
|
|
476
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
477
|
+
|
|
478
|
+
result = await search_roadmap(modified_within_days=9999)
|
|
479
|
+
|
|
480
|
+
assert result["filters_applied"]["modified_within_days"] == 365
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@pytest.mark.asyncio
|
|
484
|
+
async def test_modified_within_days_features_have_modified_date():
|
|
485
|
+
"""All returned features should have a modified date when filtering by recency."""
|
|
486
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
487
|
+
|
|
488
|
+
result = await search_roadmap(modified_within_days=365, limit=100)
|
|
489
|
+
|
|
490
|
+
for feature in result["features"]:
|
|
491
|
+
assert feature["modified"] is not None
|
|
492
|
+
assert feature["modified"] != ""
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@pytest.mark.asyncio
|
|
496
|
+
async def test_modified_within_days_combined_with_other():
|
|
497
|
+
"""modified_within_days can be combined with other filters."""
|
|
498
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
499
|
+
|
|
500
|
+
result = await search_roadmap(product="Teams", modified_within_days=365, limit=5)
|
|
501
|
+
|
|
502
|
+
assert result["filters_applied"].get("product") == "Teams"
|
|
503
|
+
assert result["filters_applied"].get("modified_within_days") == 365
|
|
504
|
+
for feature in result["features"]:
|
|
505
|
+
assert any("teams" in tag.lower() for tag in feature["tags"])
|
|
506
|
+
assert feature["modified"] is not None
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
# combined new filters
|
|
511
|
+
# ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@pytest.mark.asyncio
|
|
515
|
+
async def test_combined_new_filters():
|
|
516
|
+
"""All new filters can be combined together."""
|
|
517
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
518
|
+
|
|
519
|
+
result = await search_roadmap(
|
|
520
|
+
release_phase="General Availability",
|
|
521
|
+
platform="Web",
|
|
522
|
+
rollout_date="2026",
|
|
523
|
+
modified_within_days=365,
|
|
524
|
+
limit=10,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
assert isinstance(result["features"], list)
|
|
528
|
+
assert result["filters_applied"].get("release_phase") == "General Availability"
|
|
529
|
+
assert result["filters_applied"].get("platform") == "Web"
|
|
530
|
+
assert result["filters_applied"].get("rollout_date") == "2026"
|
|
531
|
+
assert result["filters_applied"].get("modified_within_days") == 365
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@pytest.mark.asyncio
|
|
535
|
+
async def test_combined_date_filters():
|
|
536
|
+
"""Date filters can be combined together."""
|
|
537
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
538
|
+
|
|
539
|
+
result = await search_roadmap(
|
|
540
|
+
rollout_date="2026",
|
|
541
|
+
preview_date="2026",
|
|
542
|
+
limit=10,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
assert isinstance(result["features"], list)
|
|
546
|
+
for feature in result["features"]:
|
|
547
|
+
assert feature["public_disclosure_date"] is not None
|
|
548
|
+
assert "2026" in feature["public_disclosure_date"]
|
|
549
|
+
assert feature["public_preview_date"] is not None
|
|
550
|
+
assert "2026" in feature["public_preview_date"]
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@pytest.mark.asyncio
|
|
554
|
+
async def test_combined_recency_filters():
|
|
555
|
+
"""Both recency filters can be combined."""
|
|
556
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
557
|
+
|
|
558
|
+
result = await search_roadmap(
|
|
559
|
+
added_within_days=365,
|
|
560
|
+
modified_within_days=365,
|
|
561
|
+
limit=10,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
assert isinstance(result["features"], list)
|
|
565
|
+
for feature in result["features"]:
|
|
566
|
+
assert feature["created"] is not None
|
|
567
|
+
assert feature["modified"] is not None
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ---------------------------------------------------------------------------
|
|
571
|
+
# regression test - original issue
|
|
572
|
+
# ---------------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@pytest.mark.asyncio
|
|
576
|
+
async def test_december_2026_includes_universal_print():
|
|
577
|
+
"""December 2026 search should include Universal Print feature (original issue)."""
|
|
578
|
+
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
579
|
+
|
|
580
|
+
# API uses format "December CY2026", not "December 2026"
|
|
581
|
+
result = await search_roadmap(rollout_date="December CY2026", limit=100)
|
|
582
|
+
|
|
583
|
+
# Check if Universal Print feature is in the results
|
|
584
|
+
titles = [f["title"].lower() for f in result["features"]]
|
|
585
|
+
has_universal_print = any("universal print" in title for title in titles)
|
|
586
|
+
|
|
587
|
+
# This should be true if the original issue is fixed
|
|
588
|
+
assert result["total_found"] > 0, "Should return features for December CY2026"
|
|
589
|
+
assert has_universal_print, "Universal Print feature should be in December CY2026 results"
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
"""Tests for MCP tools."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
# ---------------------------------------------------------------------------
|
|
7
|
-
# search_roadmap
|
|
8
|
-
# ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@pytest.mark.asyncio
|
|
12
|
-
async def test_search_no_filters_returns_recent():
|
|
13
|
-
"""When called with no filters, returns the most recent features."""
|
|
14
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
15
|
-
|
|
16
|
-
result = await search_roadmap(limit=5)
|
|
17
|
-
|
|
18
|
-
assert isinstance(result, dict)
|
|
19
|
-
assert "total_found" in result
|
|
20
|
-
assert "features" in result
|
|
21
|
-
assert "filters_applied" in result
|
|
22
|
-
assert len(result["features"]) <= 5
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@pytest.mark.asyncio
|
|
26
|
-
async def test_search_by_keyword():
|
|
27
|
-
"""Keyword filter matches title or description."""
|
|
28
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
29
|
-
|
|
30
|
-
result = await search_roadmap(query="Microsoft", limit=5)
|
|
31
|
-
|
|
32
|
-
assert isinstance(result["features"], list)
|
|
33
|
-
for feature in result["features"]:
|
|
34
|
-
text = (feature["title"] + feature["description"]).lower()
|
|
35
|
-
assert "microsoft" in text
|
|
36
|
-
assert result["filters_applied"].get("query") == "Microsoft"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@pytest.mark.asyncio
|
|
40
|
-
async def test_search_by_product():
|
|
41
|
-
"""Product filter returns features tagged with the given product."""
|
|
42
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
43
|
-
|
|
44
|
-
result = await search_roadmap(product="Teams", limit=5)
|
|
45
|
-
|
|
46
|
-
assert isinstance(result["features"], list)
|
|
47
|
-
assert len(result["features"]) <= 5
|
|
48
|
-
for feature in result["features"]:
|
|
49
|
-
assert any("teams" in tag.lower() for tag in feature["tags"])
|
|
50
|
-
assert result["filters_applied"].get("product") == "Teams"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@pytest.mark.asyncio
|
|
54
|
-
async def test_search_by_status():
|
|
55
|
-
"""Status filter returns only matching status."""
|
|
56
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
57
|
-
|
|
58
|
-
result = await search_roadmap(status="In development", limit=5)
|
|
59
|
-
|
|
60
|
-
for feature in result["features"]:
|
|
61
|
-
assert feature["status"].lower() == "in development"
|
|
62
|
-
assert result["filters_applied"].get("status") == "In development"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
@pytest.mark.asyncio
|
|
66
|
-
async def test_search_by_cloud_instance():
|
|
67
|
-
"""Cloud instance filter returns features available for that instance."""
|
|
68
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
69
|
-
|
|
70
|
-
result = await search_roadmap(cloud_instance="GCC", limit=5)
|
|
71
|
-
|
|
72
|
-
assert isinstance(result["features"], list)
|
|
73
|
-
for feature in result["features"]:
|
|
74
|
-
assert any("gcc" in ci.lower() for ci in feature["cloud_instances"])
|
|
75
|
-
assert result["filters_applied"].get("cloud_instance") == "GCC"
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
@pytest.mark.asyncio
|
|
79
|
-
async def test_search_by_feature_id():
|
|
80
|
-
"""Feature ID lookup retrieves a single specific feature."""
|
|
81
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
82
|
-
|
|
83
|
-
# First get a valid ID
|
|
84
|
-
recent = await search_roadmap(limit=1)
|
|
85
|
-
if recent["features"]:
|
|
86
|
-
fid = recent["features"][0]["id"]
|
|
87
|
-
result = await search_roadmap(feature_id=fid)
|
|
88
|
-
|
|
89
|
-
assert result["total_found"] == 1
|
|
90
|
-
assert result["features"][0]["id"] == fid
|
|
91
|
-
assert result["filters_applied"].get("feature_id") == fid
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@pytest.mark.asyncio
|
|
95
|
-
async def test_search_by_feature_id_not_found():
|
|
96
|
-
"""Feature ID lookup for nonexistent ID returns empty results."""
|
|
97
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
98
|
-
|
|
99
|
-
result = await search_roadmap(feature_id="nonexistent-id-99999")
|
|
100
|
-
|
|
101
|
-
assert result["total_found"] == 0
|
|
102
|
-
assert result["features"] == []
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
@pytest.mark.asyncio
|
|
106
|
-
async def test_search_combined_filters():
|
|
107
|
-
"""Multiple filters can be combined."""
|
|
108
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
109
|
-
|
|
110
|
-
result = await search_roadmap(
|
|
111
|
-
query="Microsoft",
|
|
112
|
-
status="In development",
|
|
113
|
-
limit=5,
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
assert isinstance(result, dict)
|
|
117
|
-
for feature in result["features"]:
|
|
118
|
-
text = (feature["title"] + feature["description"]).lower()
|
|
119
|
-
assert "microsoft" in text
|
|
120
|
-
assert feature["status"].lower() == "in development"
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
@pytest.mark.asyncio
|
|
124
|
-
async def test_search_limit_clamping():
|
|
125
|
-
"""Limit is clamped between 1 and 100."""
|
|
126
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
127
|
-
|
|
128
|
-
result_low = await search_roadmap(limit=0)
|
|
129
|
-
assert len(result_low["features"]) >= 0 # limit clamped to 1
|
|
130
|
-
assert len(result_low["features"]) <= 1
|
|
131
|
-
|
|
132
|
-
result_high = await search_roadmap(limit=999)
|
|
133
|
-
assert len(result_high["features"]) <= 100
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
@pytest.mark.asyncio
|
|
137
|
-
async def test_search_output_structure():
|
|
138
|
-
"""Output includes all expected keys and correct types."""
|
|
139
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
140
|
-
|
|
141
|
-
result = await search_roadmap(limit=1)
|
|
142
|
-
|
|
143
|
-
assert "total_found" in result
|
|
144
|
-
assert "features" in result
|
|
145
|
-
assert "filters_applied" in result
|
|
146
|
-
assert isinstance(result["total_found"], int)
|
|
147
|
-
assert isinstance(result["features"], list)
|
|
148
|
-
assert isinstance(result["filters_applied"], dict)
|
|
149
|
-
|
|
150
|
-
if result["features"]:
|
|
151
|
-
feature = result["features"][0]
|
|
152
|
-
assert "id" in feature
|
|
153
|
-
assert "title" in feature
|
|
154
|
-
assert "description" in feature
|
|
155
|
-
assert "status" in feature
|
|
156
|
-
assert "tags" in feature
|
|
157
|
-
assert "cloud_instances" in feature
|
|
158
|
-
assert "public_disclosure_date" in feature
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# ---------------------------------------------------------------------------
|
|
162
|
-
# added_within_days
|
|
163
|
-
# ---------------------------------------------------------------------------
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
@pytest.mark.asyncio
|
|
167
|
-
async def test_added_within_days_basic():
|
|
168
|
-
"""added_within_days returns features and reports the filter."""
|
|
169
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
170
|
-
|
|
171
|
-
result = await search_roadmap(added_within_days=30)
|
|
172
|
-
|
|
173
|
-
assert isinstance(result, dict)
|
|
174
|
-
assert isinstance(result["features"], list)
|
|
175
|
-
assert result["filters_applied"].get("added_within_days") == 30
|
|
176
|
-
assert "cutoff_date" in result["filters_applied"]
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
@pytest.mark.asyncio
|
|
180
|
-
async def test_added_within_days_larger_window_gte_smaller():
|
|
181
|
-
"""A larger time window should return >= features than a smaller one."""
|
|
182
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
183
|
-
|
|
184
|
-
small = await search_roadmap(added_within_days=7, limit=100)
|
|
185
|
-
large = await search_roadmap(added_within_days=90, limit=100)
|
|
186
|
-
|
|
187
|
-
assert large["total_found"] >= small["total_found"]
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
@pytest.mark.asyncio
|
|
191
|
-
async def test_added_within_days_clamping_low():
|
|
192
|
-
"""Days below 1 is clamped to 1."""
|
|
193
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
194
|
-
|
|
195
|
-
result = await search_roadmap(added_within_days=0)
|
|
196
|
-
|
|
197
|
-
assert result["filters_applied"]["added_within_days"] == 1
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
@pytest.mark.asyncio
|
|
201
|
-
async def test_added_within_days_clamping_high():
|
|
202
|
-
"""Days above 365 is clamped to 365."""
|
|
203
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
204
|
-
|
|
205
|
-
result = await search_roadmap(added_within_days=9999)
|
|
206
|
-
|
|
207
|
-
assert result["filters_applied"]["added_within_days"] == 365
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
@pytest.mark.asyncio
|
|
211
|
-
async def test_added_within_days_features_have_created_date():
|
|
212
|
-
"""All returned features should have a created date when filtering by recency."""
|
|
213
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
214
|
-
|
|
215
|
-
result = await search_roadmap(added_within_days=365, limit=100)
|
|
216
|
-
|
|
217
|
-
for feature in result["features"]:
|
|
218
|
-
assert feature["created"] is not None
|
|
219
|
-
assert feature["created"] != ""
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
@pytest.mark.asyncio
|
|
223
|
-
async def test_added_within_days_combined_with_product():
|
|
224
|
-
"""added_within_days can be combined with other filters."""
|
|
225
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
226
|
-
|
|
227
|
-
result = await search_roadmap(product="Teams", added_within_days=365, limit=5)
|
|
228
|
-
|
|
229
|
-
assert result["filters_applied"].get("product") == "Teams"
|
|
230
|
-
assert result["filters_applied"].get("added_within_days") == 365
|
|
231
|
-
for feature in result["features"]:
|
|
232
|
-
assert any("teams" in tag.lower() for tag in feature["tags"])
|
|
233
|
-
assert feature["created"] is not None
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
@pytest.mark.asyncio
|
|
237
|
-
async def test_added_within_days_none_means_no_filter():
|
|
238
|
-
"""When added_within_days is None, no recency filter is applied."""
|
|
239
|
-
from m365_roadmap_mcp.tools.search import search_roadmap
|
|
240
|
-
|
|
241
|
-
result = await search_roadmap(limit=5)
|
|
242
|
-
|
|
243
|
-
assert "added_within_days" not in result["filters_applied"]
|
|
244
|
-
assert "cutoff_date" not in result["filters_applied"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|