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
+70 -93
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, api, apiRequest, getStoredToken, setStoredToken } from "@/lib/api";
import { getRealtimeClient } from "@/lib/ws";
describe("token storage", () => {
it("round-trips a token through localStorage", () => {
@@ -25,22 +26,14 @@ describe("ApiError", () => {
});
});
describe("error correlation", () => {
describe("error correlation over rpc", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("reads the request id out of the error envelope", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
error: { code: "internal_error", message: "خطا", request_id: "req-from-body" },
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
),
),
vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue(
new ApiError(500, "internal_error", "خطا", undefined, "req-from-body"),
);
await expect(apiRequest("/api/whatever")).rejects.toMatchObject({
@@ -49,142 +42,126 @@ describe("error correlation", () => {
});
});
it("falls back to the X-Request-ID header", async () => {
// A failure raised outside the error handlers — a CORS rejection or a proxy
// error — still carries the header, and the user should still get an id.
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify({ detail: "Disallowed CORS origin" }), {
status: 400,
headers: { "X-Request-ID": "req-from-header" },
}),
),
it("rejects an unauthenticated call with the server's 401", async () => {
vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue(
new ApiError(401, "unauthenticated", "نیاز به ورود دارید."),
);
await expect(apiRequest("/api/whatever")).rejects.toMatchObject({
requestId: "req-from-header",
await expect(apiRequest("/api/organizations")).rejects.toMatchObject({
status: 401,
code: "unauthenticated",
});
});
});
describe("error console client", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("omits unset filters from the query string", async () => {
const calls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
calls.push(url);
return new Response(JSON.stringify({ items: [], total: 0, limit: 50, offset: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
);
const rpc = vi
.spyOn(getRealtimeClient(), "rpc")
.mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 } as unknown as never);
await api.adminErrors({ status: "open", severity: "critical" });
expect(calls[0]).toContain("status=open");
expect(calls[0]).toContain("severity=critical");
expect(rpc).toHaveBeenCalledTimes(1);
const [method, path] = rpc.mock.calls[0];
expect(method).toBe("GET");
expect(path).toContain("status=open");
expect(path).toContain("severity=critical");
// An empty string means "all", and must not be sent as a filter value.
expect(calls[0]).not.toContain("source=");
expect(calls[0]).not.toContain("search=");
expect(path).not.toContain("source=");
expect(path).not.toContain("search=");
});
it("sends no query string at all when there are no filters", async () => {
const calls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
calls.push(url);
return new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
);
const rpc = vi
.spyOn(getRealtimeClient(), "rpc")
.mockResolvedValue([] as unknown as never);
await api.adminErrorGroups();
expect(calls[0]).not.toContain("?");
const [, path] = rpc.mock.calls[0];
expect(path).not.toContain("?");
});
});
describe("agy account client", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function stubJson(body: unknown) {
const calls: { method: string; url: string; body: string }[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({
method: init?.method ?? "GET",
url,
body: String(init?.body ?? ""),
});
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
);
return calls;
function stubRpc(body: unknown) {
return vi
.spyOn(getRealtimeClient(), "rpc")
.mockResolvedValue(body as unknown as never);
}
it("posts to the login start endpoint", async () => {
const calls = stubJson({ login_id: "l1", auth_url: "https://accounts.google.com/..." });
const rpc = stubRpc({ login_id: "l1", auth_url: "https://accounts.google.com/..." });
await api.agyStartLogin();
expect(calls[0].method).toBe("POST");
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/start");
const [method, path] = rpc.mock.calls[0];
expect(method).toBe("POST");
expect(path).toContain("/api/admin/agy/accounts/login/start");
});
it("sends the pasted callback URL back verbatim", async () => {
const calls = stubJson({ id: "a1", email: "x@gmail.com" });
const rpc = stubRpc({ id: "a1", email: "x@gmail.com" });
await api.agyCompleteLogin({
login_id: "l1",
callback_url: "http://localhost:51121/oauth-callback?code=c&state=s",
});
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/complete");
expect(calls[0].body).toContain("callback_url");
const [method, path, sentBody] = rpc.mock.calls[0];
expect(path).toContain("/api/admin/agy/accounts/login/complete");
expect(JSON.stringify(sentBody)).toContain("callback_url");
});
it("routes activate/deactivate to the right verb", async () => {
const calls = stubJson({ id: "a1" });
const rpc = stubRpc({ id: "a1" });
await api.agySetAccountActive("a1", false);
await api.agySetAccountActive("a1", true);
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/deactivate");
expect(calls[1].url).toContain("/api/admin/agy/accounts/a1/activate");
expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1/deactivate");
expect(rpc.mock.calls[1][1]).toContain("/api/admin/agy/accounts/a1/activate");
});
it("deletes with the DELETE verb", async () => {
const calls = stubJson(null);
const rpc = stubRpc(null);
await api.agyDeleteAccount("a1");
expect(calls[0].method).toBe("DELETE");
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1");
expect(rpc.mock.calls[0][0]).toBe("DELETE");
expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1");
});
it("fetches the live model catalog for one account", async () => {
const calls = stubJson(["gemini-3-flash", "gemini-3-pro-high"]);
const rpc = stubRpc([
{
id: "gemini-3-flash",
display_name: "Gemini 3 Flash",
supports_thinking: true,
supported_efforts: ["low", "medium", "high"],
},
{
id: "gemini-3-pro-high",
display_name: "Gemini 3 Pro High",
supports_thinking: true,
supported_efforts: ["high"],
},
]);
const models = await api.agyAccountModels("a1");
expect(calls[0].method).toBe("GET");
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/models");
expect(models).toContain("gemini-3-pro-high");
const [method, path] = rpc.mock.calls[0];
expect(method).toBe("GET");
expect(path).toContain("/api/admin/agy/accounts/a1/models");
expect(models.map((m) => m.id)).toContain("gemini-3-pro-high");
expect(models[0].supported_efforts).toEqual(["low", "medium", "high"]);
});
it("creates a model from the cascade — no manual names", async () => {
const calls = stubJson({ id: "m1", internal_name: "gemini-3-pro-high" });
const rpc = stubRpc({ id: "m1", internal_name: "gemini-3-pro-high" });
await api.adminCreateModel({
internal_name: "gemini-3-pro-high",
capabilities: ["text", "reasoning"],
reasoning_effort: "high",
});
expect(calls[0].url).toContain("/api/admin/models");
expect(calls[0].body).toContain("gemini-3-pro-high");
expect(calls[0].body).toContain("reasoning_effort");
const [method, path, sentBody] = rpc.mock.calls[0];
expect(path).toContain("/api/admin/models");
expect(JSON.stringify(sentBody)).toContain("gemini-3-pro-high");
expect(JSON.stringify(sentBody)).toContain("reasoning_effort");
});
});
+29 -2
View File
@@ -3,6 +3,7 @@ import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { AppShell } from "@/components/AppShell";
import { RealtimeProvider } from "@/lib/realtime";
import { APP_VERSION, APP_VERSION_LABEL } from "@/lib/version";
/**
@@ -30,6 +31,32 @@ vi.mock("@/lib/session", () => ({
}),
}));
// The shell renders a live-feed indicator, so it must be mounted the way the
// app wires it in ``app/layout.tsx`` — inside ``RealtimeProvider``. The socket
// itself is stubbed out: a test must not open a real WebSocket.
vi.mock("@/lib/ws", () => {
const listener = () => () => {};
const client = {
getStatus: () => "offline" as const,
onStatus: listener,
connect: vi.fn(),
disconnect: vi.fn(),
subscribe: () => () => {},
rpc: vi.fn(),
};
return { getRealtimeClient: () => client, websocketUrl: () => "ws://localhost/api/ws" };
});
function renderShell() {
return render(
<RealtimeProvider>
<AppShell>
<p>بدنه</p>
</AppShell>
</RealtimeProvider>,
);
}
const manifest = JSON.parse(
readFileSync(join(process.cwd(), "package.json"), "utf-8"),
) as { version: string };
@@ -52,12 +79,12 @@ describe("app version", () => {
describe("sidebar version badge", () => {
it("shows the version in the shell footer", () => {
render(<AppShell><p>بدنه</p></AppShell>);
renderShell();
expect(screen.getByText(APP_VERSION_LABEL)).toBeInTheDocument();
});
it("sets the direction explicitly so the RTL shell cannot reorder it", () => {
render(<AppShell><p>بدنه</p></AppShell>);
renderShell();
const badge = screen.getByText(APP_VERSION_LABEL);
expect(badge.className).toContain("ltr-inline");
});