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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user