from __future__ import annotations import json import logging from collections import defaultdict from typing import Any from aiohttp import web logger = logging.getLogger(__name__) DEFAULT_MOCK_API_PORT = 18999 class MockNextcloudTalkAPI: """Simulates Nextcloud Talk OCS API for integration testing.""" def __init__(self, port: int = DEFAULT_MOCK_API_PORT): self._port = port self._app: web.Application | None = None self._runner: web.AppRunner | None = None self._site: web.TCPSite | None = None self._conversations: dict[str, dict[str, Any]] = {} self._messages: dict[str, list[dict[str, Any]]] = defaultdict(list) self._reactions: dict[str, dict[str, Any]] = {} self._polls: dict[str, dict[str, Any]] = {} self._msg_id_counter: int = 1000 self._capabilities = { "ocs": { "meta": {"status": "ok", "statuscode": 200}, "data": { "version": {"string": "20.0.0", "edition": "Enterprise"}, "capabilities": {"spreed": {"version": "20.0.0"}}, }, } } @property def port(self) -> int: return self._port @property def base_url(self) -> str: return f"http://127.0.0.1:{self._port}" def _ocs_ok(self, data: Any = None) -> dict[str, Any]: return {"ocs": {"meta": {"status": "ok", "statuscode": 200}, "data": data or {}}} def _ocs_created(self, data: Any = None) -> dict[str, Any]: return {"ocs": {"meta": {"status": "ok", "statuscode": 201}, "data": data or {}}} def add_conversation(self, token: str, conv_type: int = 2, name: str = "") -> None: self._conversations[token] = { "token": token, "type": conv_type, "name": name, "displayName": name, } def add_message(self, token: str, message: dict[str, Any]) -> int: msg_id = self._msg_id_counter self._msg_id_counter += 1 self._messages[token].append({"id": msg_id, **message}) return msg_id async def start(self) -> None: self._app = web.Application() self._app.router.add_get("/ocs/v2.php/cloud/capabilities", self._handle_capabilities) self._app.router.add_get("/ocs/v2.php/apps/spreed/api/v4/conversation", self._handle_conversation_list) self._app.router.add_post("/ocs/v2.php/apps/spreed/api/v4/chat/{token}", self._handle_send_chat) self._app.router.add_put("/ocs/v2.php/apps/spreed/api/v4/chat/{token}/{msg_id}", self._handle_edit_chat) self._app.router.add_delete("/ocs/v2.php/apps/spreed/api/v4/chat/{token}/{msg_id}", self._handle_delete_chat) self._app.router.add_post("/ocs/v2.php/apps/spreed/api/v4/reaction/{token}/{msg_id}", self._handle_reaction) self._app.router.add_delete("/ocs/v2.php/apps/spreed/api/v4/reaction/{token}/{msg_id}", self._handle_reaction_delete) self._app.router.add_get("/ocs/v2.php/apps/spreed/api/v4/reaction/{token}/{msg_id}", self._handle_get_reactions) self._app.router.add_post("/ocs/v2.php/apps/spreed/api/v4/poll/{token}", self._handle_create_poll) self._app.router.add_get("/{tail:.*}", self._handle_fallback) self._runner = web.AppRunner(self._app) await self._runner.setup() self._site = web.TCPSite(self._runner, "127.0.0.1", self._port) await self._site.start() logger.info(f"[MockNC] Mock API started on {self.base_url}") async def stop(self) -> None: if self._runner: await self._runner.cleanup() self._runner = None self._site = None self._app = None logger.info("[MockNC] Mock API stopped") async def _handle_capabilities(self, request: web.Request) -> web.Response: return web.json_response(self._capabilities) async def _handle_conversation_list(self, request: web.Request) -> web.Response: return web.json_response(self._ocs_ok(list(self._conversations.values()))) async def _handle_send_chat(self, request: web.Request) -> web.Response: token = request.match_info["token"] try: raw = await request.json() except Exception: raw = {} msg_id = self._msg_id_counter self._msg_id_counter += 1 return web.json_response(self._ocs_created({"id": msg_id, "token": token, "message": raw.get("message", "")})) async def _handle_edit_chat(self, request: web.Request) -> web.Response: return web.json_response(self._ocs_ok({})) async def _handle_delete_chat(self, request: web.Request) -> web.Response: return web.json_response(self._ocs_ok({})) async def _handle_reaction(self, request: web.Request) -> web.Response: token = request.match_info["token"] msg_id = request.match_info["msg_id"] try: raw = await request.json() except Exception: raw = {} key = f"{token}:{msg_id}" current = self._reactions.get(key, {}) emoji = raw.get("reaction", "") current[emoji] = current.get(emoji, 0) + 1 self._reactions[key] = current return web.json_response(self._ocs_ok(current)) async def _handle_reaction_delete(self, request: web.Request) -> web.Response: token = request.match_info["token"] msg_id = request.match_info["msg_id"] try: raw = await request.json() except Exception: raw = {} key = f"{token}:{msg_id}" current = self._reactions.get(key, {}) emoji = raw.get("reaction", "") if current.get(emoji, 0) > 0: current[emoji] -= 1 if current[emoji] <= 0: current.pop(emoji, None) self._reactions[key] = current return web.json_response(self._ocs_ok(current)) async def _handle_get_reactions(self, request: web.Request) -> web.Response: token = request.match_info["token"] msg_id = request.match_info["msg_id"] key = f"{token}:{msg_id}" return web.json_response(self._ocs_ok(self._reactions.get(key, {}))) async def _handle_create_poll(self, request: web.Request) -> web.Response: token = request.match_info["token"] try: raw = await request.json() except Exception: raw = {} poll_id = self._msg_id_counter self._msg_id_counter += 1 self._polls[str(poll_id)] = { "id": poll_id, "token": token, "question": raw.get("question", ""), "options": raw.get("options", []), } return web.json_response(self._ocs_created({"id": poll_id})) async def _handle_fallback(self, request: web.Request) -> web.Response: return web.json_response(self._ocs_ok({}))