Merge origin/dev: لایهٔ realtime (WebSocket/RPC) + فلو دعوت عضو

ادغام ۱۲ کامیت ریموت (لایهٔ realtime روی WebSocket، حذف REST عمومی، رفع
باگ‌های صفحهٔ مدل‌ها) با ۸ کامیت محلی (ارائه‌دهندهٔ agy + رفع فلو دعوت).

تنها کانفلیکت واقعی در `app/main.py` بود و ماهوی بود، نه متنی:
ریموت اپ عمومی را به «فقط /health + /api/ws» کاهش داده و همهٔ هندلرهای HTTP
را به اپ داخلی `interfaces/realtime/rpc_app.py` منتقل کرده است. بنابراین
روتر `invites` من نباید در اپ عمومی mount شود.

راه‌حل:
- `app/main.py`: فقط `health` و `realtime_router` می‌مانند؛ ایمپورت‌های اضافی
  حذف شدند (نسخهٔ ریموت).
- `interfaces/realtime/rpc_app.py`: روتر `invites` به اپ داخلی اضافه شد تا
  `GET /api/invites/{token}` و `POST /api/invites/accept` از مسیر WS-RPC
  قابل دسترس باشند.

`api.ts` و `contracts/index.ts` خودکار و بدون کانفلیکت ادغام شدند: کلاینت
`previewInvite`/`acceptInvite` حالا از `getRealtimeClient().rpc()` رد می‌شود،
پس هیچ تغییری در فراخوانی‌ها لازم نبود.

رفع تست شکستهٔ ریموت: `src/test/version.test.tsx` مقدار `AppShell` را بدون
`RealtimeProvider` رندر می‌کرد و `useRealtime` استثنا می‌داد. حالا اسکلت
همان‌طور که در `app/layout.tsx` سیم‌کشی شده رندر می‌شود و سوکت در تست stub
شده است.

اعتبارسنجی روی درخت ادغام‌شده:
- بک‌اند ۲۷۰ تست سبز (شامل ۶ تست دعوت روی معماری جدید)؛ mypy تمیز.
- فرانت ۶۱ تست سبز؛ tsc و eslint تمیز.
- تأیید شد روترهای invite روی اپ داخلی RPC حاضرند و سطح عمومی تغییر نکرده.

نکته: ۵ خطای ruff در `tests/conftest.py`، `tests/test_error_log.py` و
`tests/test_realtime.py` از خود `origin/dev` می‌آیند و به این ادغام ربطی
ندارند؛ دست‌نخورده رها شدند.
This commit is contained in:
mjalmoudy94
2026-10-13 18:09:03 +03:30
49 changed files with 4370 additions and 294 deletions
+17 -1
View File
@@ -24,7 +24,11 @@ from app.core.config import settings
from app.core.database import Base, get_session
from app.domain.base import new_id
from app.infrastructure.db.models import UserRow
from app.main import app
# The internal "full" app holds every HTTP handler; the public app (app.main.app)
# now serves only /health + /api/ws, so REST tests must target the full app.
from app.interfaces.realtime.rpc_app import get_rpc_app
app = get_rpc_app()
@pytest.fixture(scope="session", autouse=True)
@@ -39,6 +43,18 @@ def _production_shaped_jwt_secret():
settings.jwt_secret = "test-only-jwt-secret-with-more-than-32-bytes"
@pytest.fixture(scope="session", autouse=True)
def _hermetic_realtime_transport():
"""Pin the realtime bus to in-process delivery.
``auto`` would make every publish attempt a Redis connection to
``localhost:6379`` — the suite has no broker, so each event would add a
connection timeout and log noise. The fan-out behaviour under test does not
depend on the transport.
"""
settings.realtime_transport = "local"
@pytest_asyncio.fixture
async def engine():
# StaticPool keeps one connection alive so the in-memory schema survives
+131 -52
View File
@@ -27,9 +27,7 @@ from tests.conftest import auth_header, signup
# ---------------------------------------------------------------------------
GOOD_GENERATE: dict = {
"candidates": [
{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}
],
"candidates": [{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}],
"usageMetadata": {
"promptTokenCount": 7,
"candidatesTokenCount": 3,
@@ -192,9 +190,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token):
headers=auth_header(platform_owner_token),
json={
"login_id": login_id,
"callback_url": (
f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"
),
"callback_url": (f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"),
},
)
assert completed.status_code == 201, completed.text
@@ -209,9 +205,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token):
assert "agy-proj-123" in completed.text # project id is admin metadata
# 3. the account is listed.
listed = await client.get(
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
)
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
assert listed.status_code == 200
emails = [a["email"] for a in listed.json()]
assert emails == ["person@gmail.com"]
@@ -232,9 +226,7 @@ async def test_agy_complete_login_rejects_unrelated_url(client, google, platform
)
assert completed.status_code == 422
# The failed attempt must not have created an account.
listed = await client.get(
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
)
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
assert listed.json() == []
@@ -249,9 +241,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token)
headers=auth_header(platform_owner_token),
json={
"login_id": started.json()["login_id"],
"callback_url": (
f"http://localhost:51121/oauth-callback?code=c&state={state}"
),
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
},
)
account_id = created.json()["id"]
@@ -270,9 +260,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token)
headers=auth_header(platform_owner_token),
)
assert deleted.status_code == 204
listed = await client.get(
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
)
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
assert listed.json() == []
@@ -289,15 +277,11 @@ async def test_agy_same_account_twice_replaces(client, google, platform_owner_to
headers=auth_header(platform_owner_token),
json={
"login_id": started.json()["login_id"],
"callback_url": (
f"http://localhost:51121/oauth-callback?code=c&state={state}"
),
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
},
)
assert done.status_code == 201
listed = await client.get(
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
)
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
assert len(listed.json()) == 1
@@ -390,9 +374,7 @@ async def test_seed_leaves_no_legacy_providers(client, seeded_install, session_f
def test_auth_url_shape():
challenge = agy.AgyAuthClient().begin()
query = parse_qs(urlparse(challenge.auth_url).query)
assert challenge.auth_url.startswith(
"https://accounts.google.com/o/oauth2/v2/auth"
)
assert challenge.auth_url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
assert query["client_id"][0] == (
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
)
@@ -410,9 +392,7 @@ async def test_paste_flow_exchanges_and_resolves_account(google):
"""start → paste callback URL → account material, verifier stays server-side."""
login_id, _auth_url = await agy.begin_login()
challenge = agy._CHALLENGES[login_id]
callback = (
f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
)
callback = f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
result = await agy.redeem_login(login_id, callback)
assert result.email == "person@gmail.com"
assert result.project_id == "agy-proj-123"
@@ -446,11 +426,10 @@ async def test_google_error_page_is_rejected(google):
async def test_refresh_token_missing_is_actionable(monkeypatch):
"""A token response without ``refresh_token`` fails loudly and tells why."""
async def handler(method, url, json_body, headers, data):
if url.endswith("/token"):
return _FakeResponse(
json_data={"access_token": "a", "expires_in": 3600}
)
return _FakeResponse(json_data={"access_token": "a", "expires_in": 3600})
return await default_handler(method, url, json_body, headers, data)
patch_google(monkeypatch, handler)
@@ -617,7 +596,8 @@ def test_response_parsing_skips_thoughts():
async def test_complete_happy_path(google, decrypted):
adapter = agy.AgyModelAdapter()
response = await adapter.complete(
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
model=_MODEL,
credentials=[_agy_credential(email="one@gmail.com")],
messages=_MESSAGES,
)
assert response.content == "سلام"
@@ -631,6 +611,7 @@ async def test_complete_happy_path(google, decrypted):
async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypted):
"""A 429 on one account must not fail the request: the pool moves on."""
async def handler(method, url, json_body, headers, data):
if url.endswith("/token"):
# Each account's refresh token maps to its own access token.
@@ -642,9 +623,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte
auth = headers.get("Authorization", "")
if auth.endswith("access-one"):
# Both base URLs hit the same quota.
return _FakeResponse(
status_code=429, text="quota", headers={"Retry-After": "120"}
)
return _FakeResponse(status_code=429, text="quota", headers={"Retry-After": "120"})
return _FakeResponse(json_data=GOOD_GENERATE)
return await default_handler(method, url, json_body, headers, data)
@@ -659,9 +638,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte
adapter._state.cursor = -1
one = _agy_credential(email="one@gmail.com", refresh_token="refresh-1")
two = _agy_credential(email="two@gmail.com", refresh_token="refresh-2")
response = await adapter.complete(
model=_MODEL, credentials=[one, two], messages=_MESSAGES
)
response = await adapter.complete(model=_MODEL, credentials=[one, two], messages=_MESSAGES)
assert response.content == "سلام"
cooldowns = [r for r in reported if r[1] == "cooldown"]
@@ -696,6 +673,7 @@ async def test_every_account_rate_limited_raises(monkeypatch, decrypted):
async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
"""401 means the OAuth grant died — bench as error, move to next account."""
async def handler(method, url, json_body, headers, data):
if url.endswith("/token"):
access = {"refresh-1": "access-one", "refresh-2": "access-two"}[
@@ -719,9 +697,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
adapter._state.cursor = -1
dead = _agy_credential(email="dead@gmail.com", refresh_token="refresh-1")
alive = _agy_credential(email="alive@gmail.com", refresh_token="refresh-2")
response = await adapter.complete(
model=_MODEL, credentials=[dead, alive], messages=_MESSAGES
)
response = await adapter.complete(model=_MODEL, credentials=[dead, alive], messages=_MESSAGES)
assert response.content == "سلام"
errors = [r for r in reported if r[1] == "error"]
assert errors and errors[0][0] == dead.id
@@ -731,6 +707,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted):
"""5xx on one backend URL is not the account's fault — try the other."""
async def handler(method, url, json_body, headers, data):
if url.endswith("/token"):
return _token_response()
@@ -743,7 +720,8 @@ async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted
patch_google(monkeypatch, handler)
adapter = agy.AgyModelAdapter()
response = await adapter.complete(
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
model=_MODEL,
credentials=[_agy_credential(email="one@gmail.com")],
messages=_MESSAGES,
)
assert response.content == "سلام"
@@ -782,10 +760,9 @@ async def test_available_models(google, decrypted):
async def test_available_models_for_one_account(google, decrypted):
"""The console asks for a specific account, so the fetch is per-account."""
adapter = agy.AgyModelAdapter()
models = await adapter.available_models_for(
_agy_credential(email="one@gmail.com")
)
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
models = await adapter.available_models_for(_agy_credential(email="one@gmail.com"))
assert [m.id for m in models] == ["claude-sonnet-4-5", "gemini-3-flash"]
assert models[1].supported_efforts == ["low", "medium", "high"]
# The list is fetched from Google every call — no cached catalog.
fetches = [c for c in google if c["url"].endswith("fetchAvailableModels")]
assert len(fetches) == 1
@@ -817,9 +794,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
headers=auth_header(platform_owner_token),
json={
"login_id": started.json()["login_id"],
"callback_url": (
f"http://localhost:51121/oauth-callback?code=c&state={state}"
),
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
},
)
).json()["id"]
@@ -829,7 +804,9 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
headers=auth_header(platform_owner_token),
)
assert response.status_code == 200, response.text
assert response.json() == ["claude-sonnet-4-5", "gemini-3-flash"]
data = response.json()
assert [m["id"] for m in data] == ["claude-sonnet-4-5", "gemini-3-flash"]
assert data[1]["supported_efforts"] == ["low", "medium", "high"]
missing = await client.get(
"/api/admin/agy/accounts/does-not-exist/models",
@@ -840,6 +817,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
"""The end-to-end path: effort on the model row reaches the payload."""
async def handler(method, url, json_body, headers, data):
if url.endswith("/token"):
return _token_response()
@@ -865,3 +843,104 @@ async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
"thinkingBudget": 4096,
"includeThoughts": True,
}
def test_parse_cli_models_output():
"""Verify parsing raw output from agy models CLI."""
sample_output = (
"Fetching available models...\n"
"gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n"
"gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)\n"
"gemini-3.8-flash-low\tGemini 3.8 Flash (Low)\n"
"claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)\n"
"claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
"gpt-oss-120b-medium\tGPT-OSS 120B (Medium)\n"
)
models = agy.AgyModelAdapter._parse_cli_models_output(sample_output)
assert len(models) == 6
m_high = next(m for m in models if m.id == "gemini-3.8-flash-high")
assert m_high.display_name == "Gemini 3.8 Flash (High)"
assert m_high.supports_thinking is True
assert m_high.supported_efforts == ["high"]
assert m_high.default_effort == "high"
m_med = next(m for m in models if m.id == "gemini-3.8-flash-medium")
assert m_med.supported_efforts == ["medium"]
assert m_med.default_effort == "medium"
m_low = next(m for m in models if m.id == "gemini-3.8-flash-low")
assert m_low.supported_efforts == ["low"]
assert m_low.default_effort == "low"
m_claude = next(m for m in models if m.id == "claude-sonnet-4-6")
assert m_claude.supports_thinking is True
m_gpt = next(m for m in models if m.id == "gpt-oss-120b-medium")
assert m_gpt.supported_efforts == ["medium"]
def test_build_generate_payload_tiered_mapping():
"""Verify virtual CLI models map to underlying tiered model with appropriate budget."""
account = agy.AgyAccount(
credential_id="cred-1",
refresh_token="rt-1",
email="test@example.com",
project_id="proj-123",
)
messages = [ChatMessage(role="user", content="Hello")]
payload_low = agy._build_generate_payload(
account,
"gemini-3.8-flash-low",
messages,
system="",
temperature=0.7,
max_tokens=4096,
)
assert payload_low["model"] == "gemini-3.8-flash-tiered"
assert payload_low["request"]["generationConfig"]["thinkingConfig"] == {
"thinkingBudget": 4096,
"includeThoughts": True,
}
payload_high = agy._build_generate_payload(
account,
"gemini-3.8-flash-high",
messages,
system="",
temperature=0.7,
max_tokens=4096,
)
assert payload_high["model"] == "gemini-3.8-flash-tiered"
assert payload_high["request"]["generationConfig"]["thinkingConfig"] == {
"thinkingBudget": 16384,
"includeThoughts": True,
}
async def test_fetch_models_via_cli_mocked(monkeypatch):
"""Verify _fetch_models_via_cli invokes the CLI binary and handles output."""
account = agy.AgyAccount(
credential_id="cred-1",
refresh_token="rt-test-123",
email="test@example.com",
project_id="proj-123",
)
class FakeProc:
returncode = 0
async def communicate(self):
return b"gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n", b""
async def fake_create_subprocess_exec(*args, **kwargs):
return FakeProc()
monkeypatch.setattr(agy.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
monkeypatch.setattr(agy.shutil, "which", lambda cmd: "/usr/bin/agy")
models = await agy.AgyModelAdapter._fetch_models_via_cli(account)
assert len(models) == 1
assert models[0].id == "gemini-3.8-flash-high"
assert models[0].display_name == "Gemini 3.8 Flash (High)"
+5 -1
View File
@@ -22,7 +22,11 @@ from app.core.errors import ProviderUnavailableError
from app.infrastructure.db.models import ErrorEventRow
from app.interfaces.http.deps import OrgContext, get_org_context
from app.interfaces.http.request_context import REQUEST_ID_HEADER
from app.main import app
# The internal "full" app holds every HTTP handler; the public app serves only
# /health + /api/ws, so this module's REST exercises must target the full app.
from app.interfaces.realtime.rpc_app import get_rpc_app
app = get_rpc_app()
from tests.conftest import auth_header, signup
#: The password in this DSN must never reach the database.
+456
View File
@@ -0,0 +1,456 @@
"""Realtime push layer: authorization, fan-out, and the live socket end-to-end.
Three levels, mirroring the package's structure:
* **Authorization** — a channel is only ever granted through
``authorize_channel``; every rule is tested for both the allow and the deny
side, because a rule that always denies is indistinguishable from a bug.
* **Fan-out** — the manager delivers to the subscribers of a channel, keeps
ordering, de-duplicates relayed events and cleans up on unsubscribe.
* **The socket** — a real WebSocket against the real app: subscribe, act over
HTTP, and assert the event arrives without a reload.
"""
from __future__ import annotations
import asyncio
from contextlib import suppress
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
import app.infrastructure.db.models as _orm_models # noqa: F401 - registers metadata
from app.application.ports.realtime import RealtimeEvent, set_publisher
from app.core.config import settings
from app.core.database import Base, get_session
from app.core.security import create_access_token
from app.domain.base import new_id
from app.infrastructure.db.models import UserRow
from app.interfaces.realtime.authz import authorize_channel
from app.interfaces.realtime.bus import get_bus
from app.interfaces.realtime.manager import Connection, ConnectionManager
# The internal "full" app holds every HTTP handler; the public app serves only
# /health + /api/ws. The socket and the RPC surface both live on the full app.
from app.interfaces.realtime.rpc_app import get_rpc_app
app = get_rpc_app()
API_ROOT = Path(__file__).resolve().parents[1]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class FakeSocket:
"""The manager only needs ``send_text`` and a connected ``client_state``.
Both matter: the sender task checks ``client_state`` before writing, so a
fake without it would silently swallow every frame and make the manager
look broken.
"""
def __init__(self) -> None:
self.sent: list[str] = []
from starlette.websockets import WebSocketState
self.client_state = WebSocketState.CONNECTED
async def send_text(self, payload: str) -> None:
self.sent.append(payload)
def _event(event_id: str, *channels: str) -> RealtimeEvent:
return RealtimeEvent(type="test.event", channels=channels, data={"id": event_id}, id=event_id)
async def _allow(_: Connection, __: str) -> bool:
return True
# ---------------------------------------------------------------------------
# Authorization
# ---------------------------------------------------------------------------
async def _user_with_org(session: AsyncSession, *, platform_owner: bool = False):
"""A user, and optionally an organization they belong to."""
user_id = new_id()
session.add(
UserRow(
id=user_id,
email=f"{user_id}@example.com",
password_hash="x",
is_platform_owner=platform_owner,
)
)
await session.flush()
return user_id
async def _org_with_member(session: AsyncSession, user_id: str) -> str:
from app.domain.identity.models import Membership, Organization, Role
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
)
organization = await OrganizationRepository(session).create(
Organization(name="سازمان تست", slug=new_id()[:8], owner_user_id=user_id)
)
await MembershipRepository(session).add(
Membership(organization_id=organization.id, user_id=user_id, role=Role.ADMIN)
)
return organization.id
@pytest.mark.asyncio
async def test_member_may_subscribe_to_own_org(session_factory):
async with session_factory() as session:
user_id = await _user_with_org(session)
org_id = await _org_with_member(session, user_id)
allowed = await authorize_channel(
session, user_id=user_id, is_platform_owner=False, channel=f"org:{org_id}"
)
assert allowed is True
@pytest.mark.asyncio
async def test_outsider_may_not_subscribe_to_foreign_org(session_factory):
async with session_factory() as session:
insider = await _user_with_org(session)
outsider = await _user_with_org(session)
org_id = await _org_with_member(session, insider)
allowed = await authorize_channel(
session, user_id=outsider, is_platform_owner=False, channel=f"org:{org_id}"
)
assert allowed is False
@pytest.mark.asyncio
async def test_unknown_channel_kinds_are_denied(session_factory):
async with session_factory() as session:
user_id = await _user_with_org(session)
allowed = await authorize_channel(
session, user_id=user_id, is_platform_owner=False, channel="nonsense:whatever"
)
assert allowed is False
@pytest.mark.asyncio
async def test_admin_channel_needs_platform_owner(session_factory):
async with session_factory() as session:
owner = await _user_with_org(session, platform_owner=True)
member = await _user_with_org(session)
owner_ok = await authorize_channel(
session, user_id=owner, is_platform_owner=True, channel="admin"
)
member_ok = await authorize_channel(
session, user_id=member, is_platform_owner=False, channel="admin"
)
assert owner_ok is True
assert member_ok is False
@pytest.mark.asyncio
async def test_user_channel_is_private(session_factory):
async with session_factory() as session:
user_id = await _user_with_org(session)
other = await _user_with_org(session)
own = await authorize_channel(
session, user_id=user_id, is_platform_owner=False, channel=f"user:{user_id}"
)
foreign = await authorize_channel(
session, user_id=other, is_platform_owner=False, channel=f"user:{user_id}"
)
assert own is True
assert foreign is False
# ---------------------------------------------------------------------------
# Fan-out
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_manager_delivers_to_subscribers_only():
manager = ConnectionManager()
listening = FakeSocket()
other = FakeSocket()
subscribed = Connection(user_id="u1", is_platform_owner=False, websocket=listening)
stranger = Connection(user_id="u2", is_platform_owner=False, websocket=other)
for connection in (subscribed, stranger):
await manager.register(connection)
await manager.subscribe(subscribed, ["org:1"], _allow)
delivered = await manager.dispatch(_event("e1", "org:1"))
assert delivered == 1
# The write happens on the connection's own sender task; give the loop the
# turn it needs to flush the queue.
await asyncio.sleep(0.05)
assert len(listening.sent) == 1
assert other.sent == []
@pytest.mark.asyncio
async def test_manager_wraps_events_in_the_wire_envelope():
"""A browser sees one discriminator: ``{"type": "event", "event": {...}}``."""
import json
manager = ConnectionManager()
socket = FakeSocket()
connection = Connection(user_id="u1", is_platform_owner=False, websocket=socket)
await manager.register(connection)
await manager.subscribe(connection, ["org:1"], _allow)
await manager.dispatch(_event("e1", "org:1"))
await asyncio.sleep(0.05)
assert len(socket.sent) == 1
frame = json.loads(socket.sent[0])
assert frame["type"] == "event"
assert frame["event"]["id"] == "e1"
assert frame["event"]["type"] == "test.event"
@pytest.mark.asyncio
async def test_manager_drops_duplicate_events():
"""In-process publish plus a relayed copy deliver exactly once."""
manager = ConnectionManager()
socket = FakeSocket()
connection = Connection(user_id="u1", is_platform_owner=False, websocket=socket)
await manager.register(connection)
await manager.subscribe(connection, ["org:1"], _allow)
event = _event("dup", "org:1")
first = await manager.dispatch(event)
second = await manager.dispatch(event)
assert (first, second) == (1, 0)
@pytest.mark.asyncio
async def test_unsubscribe_stops_delivery():
manager = ConnectionManager()
socket = FakeSocket()
connection = Connection(user_id="u1", is_platform_owner=False, websocket=socket)
await manager.register(connection)
await manager.subscribe(connection, ["org:1", "org:2"], _allow)
await manager.unsubscribe(connection, ["org:1"])
assert await manager.dispatch(_event("e1", "org:1")) == 0
assert await manager.dispatch(_event("e2", "org:2")) == 1
@pytest.mark.asyncio
async def test_publisher_never_raises_on_bad_listener():
"""A listener that explodes must not break the publisher or its peers."""
async def exploding(event: RealtimeEvent) -> None:
raise RuntimeError("boom")
bus = get_bus()
bus.attach(exploding)
try:
set_publisher(bus)
# Must not raise despite the exploding listener.
from app.application.ports.realtime import publish
await publish(_event("e1", "org:1"))
finally:
bus.detach(exploding)
set_publisher(_NullPublisher())
class _NullPublisher:
async def publish(self, event: RealtimeEvent) -> None:
return None
# ---------------------------------------------------------------------------
# The socket, end to end
# ---------------------------------------------------------------------------
async def _create_schema(engine) -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@pytest.fixture()
def ws_client(monkeypatch):
"""A TestClient whose API and socket both talk to one throwaway database.
File-based SQLite with ``NullPool`` on purpose: the app runs on the test
portal's event loop while the schema is created from the test's own, and a
pooled connection cannot cross that boundary. The database file lives in
the package directory — ``*.db`` is gitignored — so the fixture needs no
writable temp directory outside the workspace.
"""
db_path = API_ROOT / f"realtime-test-{new_id()}.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", poolclass=NullPool)
asyncio.run(_create_schema(engine))
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
monkeypatch.setattr("app.main.engine", engine)
monkeypatch.setattr("app.interfaces.realtime.router.SessionFactory", factory)
monkeypatch.setattr(settings, "realtime_transport", "local")
async def _override_session():
async with factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_session] = _override_session
# The manager is module state; start each run from a clean slate so a
# connection from another test cannot colour this one's fan-out.
from app.interfaces.realtime.router import realtime_manager
realtime_manager()._connections.clear()
realtime_manager()._by_channel.clear()
try:
with TestClient(app) as client:
yield client
finally:
app.dependency_overrides.clear()
asyncio.run(engine.dispose())
with suppress(OSError):
db_path.unlink()
def _frames_until(websocket, wanted_type: str, *, max_frames: int = 20) -> dict:
"""Read frames until ``wanted_type`` arrives — hello/subscribed come first."""
for _ in range(max_frames):
frame = websocket.receive_json()
if frame.get("type") == "event" and frame.get("event", {}).get("type") == wanted_type:
return frame["event"]
raise AssertionError(f"no {wanted_type} event arrived")
def test_socket_allows_anonymous_then_restricts_actions(ws_client):
"""No token opens an anonymous socket, but only login/signup may be called.
A visitor who has not logged in can reach ``/api/auth/login`` and
``/api/auth/signup`` over RPC; every other RPC and any subscribe is refused.
"""
with ws_client.websocket_connect("/api/ws") as socket:
hello = socket.receive_json()
assert hello["type"] == "hello"
assert hello["user_id"] is None
# A protected RPC is refused for an anonymous connection.
socket.send_json({"action": "rpc", "id": "1", "method": "GET", "path": "/api/organizations"})
result = socket.receive_json()
assert result["type"] == "rpc_result"
assert result["status"] == 401
# Subscribing is refused until the caller logs in.
socket.send_json({"action": "subscribe", "channels": ["org:x"]})
refused = socket.receive_json()
assert refused["type"] == "error"
def test_anonymous_may_call_login_over_rpc(ws_client):
with ws_client.websocket_connect("/api/ws") as socket:
socket.receive_json() # hello
socket.send_json(
{
"action": "rpc",
"id": "login-1",
"method": "POST",
"path": "/api/auth/login",
"body": {"email": "anon@example.com", "password": "password123"},
}
)
result = socket.receive_json()
assert result["type"] == "rpc_result"
# No such user -> the handler's own auth dependency returns 401, proving
# the RPC reached the real login route through the internal app.
assert result["status"] == 401
def test_task_creation_is_pushed_live(ws_client):
# Sign up, create an organization — the tenant the socket subscribes to.
signup = ws_client.post(
"/api/auth/signup",
json={"email": "live@example.com", "password": "password123", "name": "زنده"},
)
assert signup.status_code == 201, signup.text
token = signup.json()["access_token"]
org = ws_client.post(
"/api/organizations",
json={"name": "سازمان زنده"},
headers={"Authorization": f"Bearer {token}"},
)
assert org.status_code == 201, org.text
org_id = org.json()["id"]
with ws_client.websocket_connect(f"/api/ws?token={token}") as socket:
hello = socket.receive_json()
assert hello["type"] == "hello"
socket.send_json({"action": "subscribe", "channels": [f"org:{org_id}"]})
subscribed = socket.receive_json()
assert subscribed["type"] == "subscribed"
assert subscribed["accepted"] == [f"org:{org_id}"]
# A foreign channel is refused, not silently ignored.
socket.send_json({"action": "subscribe", "channels": ["org:does-not-exist"]})
refused = socket.receive_json()
assert refused["type"] == "subscribed"
assert refused["rejected"] == ["org:does-not-exist"]
created = ws_client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "کار زنده"},
headers={"Authorization": f"Bearer {token}"},
)
assert created.status_code == 201, created.text
event = _frames_until(socket, "task.created")
assert event["data"]["title"] == "کار زنده"
assert event["organization_id"] == org_id
def test_second_user_cannot_read_another_org(ws_client):
"""Two tenants, one socket each: an outsider's org is out of reach."""
tokens = []
org_ids = []
for index in range(2):
signup = ws_client.post(
"/api/auth/signup",
json={"email": f"user{index}@example.com", "password": "password123"},
)
assert signup.status_code == 201, signup.text
token = signup.json()["access_token"]
org = ws_client.post(
"/api/organizations",
json={"name": f"org {index}"},
headers={"Authorization": f"Bearer {token}"},
)
assert org.status_code == 201, org.text
tokens.append(token)
org_ids.append(org.json()["id"])
with ws_client.websocket_connect(f"/api/ws?token={tokens[0]}") as socket:
socket.receive_json() # hello
socket.send_json({"action": "subscribe", "channels": [f"org:{org_ids[1]}"]})
refused = socket.receive_json()
assert refused["rejected"] == [f"org:{org_ids[1]}"]
def test_socket_token_identifies_a_real_user(ws_client):
"""A well-formed token for a deleted user must not open a socket."""
user_id = new_id()
token = create_access_token(user_id)
with pytest.raises(Exception), ws_client.websocket_connect(f"/api/ws?token={token}"): # noqa: B017, SIM117
# Closed with 1008: the token is well-formed but names no user.
pass