|
16 | 16 | # to the current version of the project delivered to anyone in the future. |
17 | 17 | # |
18 | 18 | from unittest.mock import Mock, call, patch |
| 19 | +from uuid import uuid4 |
19 | 20 |
|
20 | 21 | import pytest |
| 22 | +from celery.schedules import crontab |
21 | 23 | from ddf import G |
22 | 24 |
|
23 | | -from apigateway.apps.mcp_server.models import MCPServer |
| 25 | +from apigateway.apps.mcp_server.constants import ( |
| 26 | + FEATURED_MCP_CATEGORY_NAME, |
| 27 | + OFFICIAL_MCP_CATEGORY_NAME, |
| 28 | + MCPServerStatusEnum, |
| 29 | +) |
| 30 | +from apigateway.apps.mcp_server.models import MCPServer, MCPServerCategory |
24 | 31 | from apigateway.apps.mcp_server.tasks import ( |
25 | 32 | _fetch_updated_prompts, |
26 | 33 | add_stage_mcp_server_permissions_before_release_update, |
27 | 34 | reconcile_stage_mcp_server_permissions_after_release, |
| 35 | + refresh_official_mcp_server_category, |
28 | 36 | sync_mcp_server_after_release, |
29 | 37 | ) |
| 38 | +from apigateway.conf.celery_conf import CELERY_BEAT_SCHEDULE |
30 | 39 | from apigateway.core.constants import ( |
31 | 40 | PublishEventNameTypeEnum, |
32 | 41 | PublishEventStatusEnum, |
33 | 42 | ReleaseHistoryStatusEnum, |
34 | 43 | ) |
35 | | -from apigateway.core.models import PublishEvent, Release, ReleaseHistory, ResourceVersion |
| 44 | +from apigateway.core.models import Gateway, PublishEvent, Release, ReleaseHistory, ResourceVersion, Stage |
36 | 45 |
|
37 | 46 |
|
38 | 47 | class TestFetchUpdatedPrompts: |
@@ -367,3 +376,146 @@ def test_save_exception_logged(self, fake_gateway, fake_stage, release_history): |
367 | 376 | release_history_id=release_history.id, |
368 | 377 | mcp_servers_data=mcp_data, |
369 | 378 | ) |
| 379 | + |
| 380 | + |
| 381 | +class TestRefreshOfficialMcpServerCategory: |
| 382 | + def _category(self, name: str, display_name: str, *, is_active: bool = True) -> MCPServerCategory: |
| 383 | + category, _ = MCPServerCategory.objects.get_or_create( |
| 384 | + name=name, |
| 385 | + defaults={"display_name": display_name, "is_active": is_active}, |
| 386 | + ) |
| 387 | + if category.is_active != is_active or category.display_name != display_name: |
| 388 | + category.is_active = is_active |
| 389 | + category.display_name = display_name |
| 390 | + category.save(update_fields=["is_active", "display_name"]) |
| 391 | + return category |
| 392 | + |
| 393 | + def _gateway(self, *, is_official: bool) -> tuple[Gateway, Stage]: |
| 394 | + gateway = G(Gateway, is_official=is_official) |
| 395 | + return gateway, G(Stage, gateway=gateway) |
| 396 | + |
| 397 | + def _server(self, gateway: Gateway, stage: Stage, **kwargs) -> MCPServer: |
| 398 | + return G( |
| 399 | + MCPServer, |
| 400 | + gateway=gateway, |
| 401 | + stage=stage, |
| 402 | + name=f"mcp-{uuid4().hex[:12]}", |
| 403 | + **kwargs, |
| 404 | + ) |
| 405 | + |
| 406 | + def _category_names(self, mcp_server: MCPServer) -> set[str]: |
| 407 | + return set(mcp_server.categories.values_list("name", flat=True)) |
| 408 | + |
| 409 | + def test_adds_official_category_to_all_servers_on_official_gateway(self): |
| 410 | + official = self._category(OFFICIAL_MCP_CATEGORY_NAME, "官方资源") |
| 411 | + gateway, stage = self._gateway(is_official=True) |
| 412 | + public_active = self._server( |
| 413 | + gateway, |
| 414 | + stage, |
| 415 | + is_public=True, |
| 416 | + status=MCPServerStatusEnum.ACTIVE.value, |
| 417 | + ) |
| 418 | + private_inactive = self._server( |
| 419 | + gateway, |
| 420 | + stage, |
| 421 | + is_public=False, |
| 422 | + status=MCPServerStatusEnum.INACTIVE.value, |
| 423 | + ) |
| 424 | + |
| 425 | + refresh_official_mcp_server_category() |
| 426 | + |
| 427 | + assert official in public_active.categories.all() |
| 428 | + assert official in private_inactive.categories.all() |
| 429 | + |
| 430 | + def test_does_not_duplicate_existing_official_category(self): |
| 431 | + official = self._category(OFFICIAL_MCP_CATEGORY_NAME, "官方资源") |
| 432 | + gateway, stage = self._gateway(is_official=True) |
| 433 | + mcp_server = self._server(gateway, stage) |
| 434 | + mcp_server.categories.add(official) |
| 435 | + |
| 436 | + refresh_official_mcp_server_category() |
| 437 | + |
| 438 | + assert list(mcp_server.categories.all()) == [official] |
| 439 | + |
| 440 | + def test_keeps_official_category_on_non_official_gateway(self): |
| 441 | + official = self._category(OFFICIAL_MCP_CATEGORY_NAME, "官方资源") |
| 442 | + gateway, stage = self._gateway(is_official=False) |
| 443 | + mcp_server = self._server(gateway, stage) |
| 444 | + mcp_server.categories.add(official) |
| 445 | + |
| 446 | + refresh_official_mcp_server_category() |
| 447 | + |
| 448 | + assert official in mcp_server.categories.all() |
| 449 | + |
| 450 | + def test_does_not_add_official_category_to_non_official_gateway(self): |
| 451 | + self._category(OFFICIAL_MCP_CATEGORY_NAME, "官方资源") |
| 452 | + gateway, stage = self._gateway(is_official=False) |
| 453 | + mcp_server = self._server(gateway, stage) |
| 454 | + |
| 455 | + refresh_official_mcp_server_category() |
| 456 | + |
| 457 | + assert not mcp_server.categories.filter(name=OFFICIAL_MCP_CATEGORY_NAME).exists() |
| 458 | + |
| 459 | + def test_preserves_featured_and_business_categories(self): |
| 460 | + official = self._category(OFFICIAL_MCP_CATEGORY_NAME, "官方资源") |
| 461 | + featured = self._category(FEATURED_MCP_CATEGORY_NAME, "精选推荐") |
| 462 | + devops = G( |
| 463 | + MCPServerCategory, |
| 464 | + name=f"DevOps-{uuid4().hex[:8]}", |
| 465 | + display_name="持续交付", |
| 466 | + is_active=True, |
| 467 | + ) |
| 468 | + gateway, stage = self._gateway(is_official=True) |
| 469 | + mcp_server = self._server(gateway, stage) |
| 470 | + mcp_server.categories.set([featured, devops]) |
| 471 | + |
| 472 | + refresh_official_mcp_server_category() |
| 473 | + |
| 474 | + assert self._category_names(mcp_server) == { |
| 475 | + official.name, |
| 476 | + featured.name, |
| 477 | + devops.name, |
| 478 | + } |
| 479 | + |
| 480 | + def test_skips_when_official_category_missing(self): |
| 481 | + MCPServerCategory.objects.filter(name=OFFICIAL_MCP_CATEGORY_NAME).delete() |
| 482 | + gateway, stage = self._gateway(is_official=True) |
| 483 | + mcp_server = self._server(gateway, stage) |
| 484 | + |
| 485 | + refresh_official_mcp_server_category() |
| 486 | + |
| 487 | + assert not mcp_server.categories.exists() |
| 488 | + |
| 489 | + def test_skips_when_official_category_inactive(self): |
| 490 | + official = self._category(OFFICIAL_MCP_CATEGORY_NAME, "官方资源", is_active=False) |
| 491 | + gateway, stage = self._gateway(is_official=True) |
| 492 | + mcp_server = self._server(gateway, stage) |
| 493 | + |
| 494 | + refresh_official_mcp_server_category() |
| 495 | + |
| 496 | + assert official not in mcp_server.categories.all() |
| 497 | + |
| 498 | + def test_does_not_update_mcp_server_business_fields(self): |
| 499 | + self._category(OFFICIAL_MCP_CATEGORY_NAME, "官方资源") |
| 500 | + gateway, stage = self._gateway(is_official=True) |
| 501 | + mcp_server = self._server( |
| 502 | + gateway, |
| 503 | + stage, |
| 504 | + title="keep-title", |
| 505 | + description="keep-desc", |
| 506 | + is_public=False, |
| 507 | + ) |
| 508 | + original_updated_time = mcp_server.updated_time |
| 509 | + |
| 510 | + refresh_official_mcp_server_category() |
| 511 | + mcp_server.refresh_from_db() |
| 512 | + |
| 513 | + assert mcp_server.title == "keep-title" |
| 514 | + assert mcp_server.description == "keep-desc" |
| 515 | + assert mcp_server.is_public is False |
| 516 | + assert mcp_server.updated_time == original_updated_time |
| 517 | + |
| 518 | + def test_hourly_schedule_is_registered(self): |
| 519 | + entry = CELERY_BEAT_SCHEDULE["apigateway.apps.mcp_server.tasks.refresh_official_mcp_server_category"] |
| 520 | + assert entry["task"] == "apigateway.apps.mcp_server.tasks.refresh_official_mcp_server_category" |
| 521 | + assert entry["schedule"] == crontab(minute=0) |
0 commit comments