import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; const { waitForTelegramWebApp } = vi.hoisted(() => ({ waitForTelegramWebApp: vi.fn(), })); // Keep the real dev-mock helpers (createDevMockWebApp / isDevMockWebApp) — // only the bridge resolver is faked. vi.mock("@/lib/telegram/webapp", async (importOriginal) => ({ ...(await importOriginal>()), waitForTelegramWebApp, })); const { startTelegramThemeSync } = vi.hoisted(() => ({ startTelegramThemeSync: vi.fn(() => () => undefined), })); vi.mock("@/lib/telegram/theme", () => ({ startTelegramThemeSync })); const { post } = vi.hoisted(() => ({ post: vi.fn() })); vi.mock("@/lib/api/client", () => ({ default: { post }, getErrorMessage: (err: unknown) => (err as { message?: string } | undefined)?.message ?? "Unknown error", })); // The cockpit tabs each fetch their own data (queue cards, tasks, // notifications, A2A) — stubbed out here since this test only exercises the // bootstrap state machine, not tab content (each tab gets its own coverage). vi.mock("@/components/tg/tg-tab-bar", () => ({ TgTabBar: () =>
, })); vi.mock("@/components/tg/tg-today-tab", () => ({ TgTodayTab: () =>
, })); vi.mock("@/components/tg/tg-approvals-tab", () => ({ TgApprovalsTab: () =>
, })); vi.mock("@/components/tg/tg-inbox-tab", () => ({ TgInboxTab: () =>
, })); vi.mock("@/components/tg/tg-board-tab", () => ({ TgBoardTab: () =>
, })); vi.mock("@/components/tg/tg-chat-tab", () => ({ TgChatTab: () =>
, })); vi.mock("@/components/tg/tg-metrics-tab", () => ({ TgMetricsTab: () =>
, })); // The shell's bell badge count — stubbed so the bootstrap test needs no // QueryClientProvider. vi.mock("@/hooks/use-notifications", () => ({ useNotifications: () => ({ data: undefined }), })); import TelegramMiniAppPage from "../page"; function mockWebApp(initData = "abc123") { return { ready: vi.fn(), expand: vi.fn(), disableVerticalSwipes: vi.fn(), initData, }; } describe("TelegramMiniAppPage — auth bootstrap", () => { beforeEach(() => { waitForTelegramWebApp.mockReset(); startTelegramThemeSync.mockClear(); post.mockReset(); }); afterEach(() => { vi.unstubAllEnvs(); }); it("shows a spinner while validating", () => { waitForTelegramWebApp.mockReturnValue(new Promise(() => {})); render(); expect(screen.getByText(/connecting/i)).toBeInTheDocument(); }); it("renders the not-inside-Telegram screen when no WebApp object exists", async () => { waitForTelegramWebApp.mockResolvedValue(null); render(); await waitFor(() => expect(screen.getByText(/open from telegram/i)).toBeInTheDocument(), ); expect(post).not.toHaveBeenCalled(); }); it("shows the Open-from-Telegram wall in production when the CDN bridge has empty initData", async () => { // A plain browser at /tg: the telegram.org script defines WebApp but // with no initData. Production must not post the empty payload (422) — // it shows the wall, same as no bridge at all. waitForTelegramWebApp.mockResolvedValue(mockWebApp("")); render(); await waitFor(() => expect(screen.getByText(/open from telegram/i)).toBeInTheDocument(), ); expect(post).not.toHaveBeenCalled(); }); it("calls ready/expand, posts initData, and renders the cockpit on success", async () => { const webApp = mockWebApp("real-init-data"); waitForTelegramWebApp.mockResolvedValue(webApp); post.mockResolvedValue({ data: { ok: true } }); render(); await waitFor(() => expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(), ); expect(webApp.ready).toHaveBeenCalledTimes(1); expect(webApp.expand).toHaveBeenCalledTimes(1); expect(webApp.disableVerticalSwipes).toHaveBeenCalledTimes(1); expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", { init_data: "real-init-data", }); // Default tab is Today. expect(screen.getByTestId("tg-today-tab")).toBeInTheDocument(); }); it("renders an error screen with the server's message when auth is refused", async () => { waitForTelegramWebApp.mockResolvedValue(mockWebApp()); post.mockRejectedValue({ message: "Mini App disabled" }); render(); await waitFor(() => expect(screen.getByText(/couldn.t sign in/i)).toBeInTheDocument(), ); expect(screen.getByText("Mini App disabled")).toBeInTheDocument(); expect(screen.queryByTestId("tg-tab-bar")).not.toBeInTheDocument(); }); it("falls back to the dev mock outside Telegram in development — no auth POST", async () => { vi.stubEnv("NODE_ENV", "development"); waitForTelegramWebApp.mockResolvedValue(null); render(); await waitFor(() => expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(), ); expect(post).not.toHaveBeenCalled(); expect(screen.queryByText(/open from telegram/i)).not.toBeInTheDocument(); }); it("dev mock also engages when the CDN bridge loaded with empty initData", async () => { // A bare browser tab still loads telegram-web-app.js, so the bridge // object exists — only a real Telegram launch carries initData. vi.stubEnv("NODE_ENV", "development"); waitForTelegramWebApp.mockResolvedValue(mockWebApp("")); render(); await waitFor(() => expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(), ); expect(post).not.toHaveBeenCalled(); }); it("starts Telegram theme sync against the #tg-shell element once ready", async () => { const shell = document.createElement("div"); shell.id = "tg-shell"; document.body.appendChild(shell); try { const webApp = mockWebApp(); waitForTelegramWebApp.mockResolvedValue(webApp); post.mockResolvedValue({ data: { ok: true } }); render(); await waitFor(() => expect(startTelegramThemeSync).toHaveBeenCalledWith(webApp, shell), ); } finally { shell.remove(); } }); });