// Login page component tests. The `auth` API and the navigation // helper are mocked at module level; the session singleton is wired // to a per-test `SessionStore`-backing IndexedDB so the keypair the // form passes to `confirmEmailCode` is a genuine 32-byte Ed25519 // public key without polluting the production `dbConnection()` // cache. import "fake-indexeddb/auto"; import { fireEvent, render, waitFor } from "@testing-library/svelte"; import { afterEach, beforeEach, describe, expect, test, vi, } from "vitest"; import type { IDBPDatabase } from "idb"; import { AuthError } from "../src/api/auth"; import { i18n } from "../src/lib/i18n/index.svelte"; import { session } from "../src/lib/session-store.svelte"; import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb"; import { IDBCache } from "../src/platform/store/idb-cache"; import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}), })); const sendEmailCodeSpy = vi.fn(); const confirmEmailCodeSpy = vi.fn(); vi.mock("../src/api/auth", async () => { const actual = await vi.importActual( "../src/api/auth", ); return { ...actual, sendEmailCode: (...args: unknown[]) => sendEmailCodeSpy(...args), confirmEmailCode: (...args: unknown[]) => confirmEmailCodeSpy(...args), }; }); let db: IDBPDatabase; let dbName: string; beforeEach(async () => { dbName = `galaxy-ui-test-${crypto.randomUUID()}`; db = await openGalaxyDB(dbName); const store = { keyStore: new WebCryptoKeyStore(db), cache: new IDBCache(db), }; session.resetForTests(); session.setStoreLoaderForTests(async () => store); await session.init(); i18n.resetForTests("en"); sendEmailCodeSpy.mockReset(); confirmEmailCodeSpy.mockReset(); }); afterEach(async () => { sendEmailCodeSpy.mockReset(); confirmEmailCodeSpy.mockReset(); session.resetForTests(); i18n.resetForTests("en"); db.close(); await new Promise((resolve) => { const req = indexedDB.deleteDatabase(dbName); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); }); }); async function importLoginPage(): Promise { return import("../src/routes/login/+page.svelte"); } describe("login page", () => { test("submitting the email step calls sendEmailCode and advances to step=code", async () => { sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); const Page = (await importLoginPage()).default; const ui = render(Page); const emailInput = ui.getByTestId("login-email-input") as HTMLInputElement; await fireEvent.input(emailInput, { target: { value: "pilot@example.com" }, }); await fireEvent.click(ui.getByTestId("login-email-submit")); await waitFor(() => { expect(sendEmailCodeSpy).toHaveBeenCalledWith( expect.any(String), "pilot@example.com", { locale: "en" }, ); expect(ui.getByTestId("login-code-input")).toBeInTheDocument(); }); }); test("a send-email-code error stays on the email step and surfaces the message", async () => { sendEmailCodeSpy.mockRejectedValueOnce( new AuthError("service_unavailable", "auth service is unavailable", 503), ); const Page = (await importLoginPage()).default; const ui = render(Page); // Use a syntactically valid e-mail so JSDOM does not block form // submission via the `type="email"` constraint; the gateway is // expected to reject the request with `service_unavailable` // regardless of the address shape. await fireEvent.input(ui.getByTestId("login-email-input"), { target: { value: "pilot@example.com" }, }); await fireEvent.click(ui.getByTestId("login-email-submit")); await waitFor(() => { expect(ui.getByTestId("login-error")).toHaveTextContent( "auth service is unavailable", ); }); expect(ui.queryByTestId("login-code-input")).toBeNull(); }); test("submitting the code step calls confirmEmailCode and signs the user in", async () => { sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); confirmEmailCodeSpy.mockResolvedValueOnce({ deviceSessionId: "dev-1" }); const Page = (await importLoginPage()).default; const ui = render(Page); await fireEvent.input(ui.getByTestId("login-email-input"), { target: { value: "pilot@example.com" }, }); await fireEvent.click(ui.getByTestId("login-email-submit")); await waitFor(() => ui.getByTestId("login-code-input")); await fireEvent.input(ui.getByTestId("login-code-input"), { target: { value: "123456" }, }); await fireEvent.click(ui.getByTestId("login-code-submit")); await waitFor(() => { expect(confirmEmailCodeSpy).toHaveBeenCalledTimes(1); expect(session.deviceSessionId).toBe("dev-1"); expect(session.status).toBe("authenticated"); }); const args = confirmEmailCodeSpy.mock.calls[0]![1]!; expect(args.challengeId).toBe("ch-1"); expect(args.code).toBe("123456"); expect(args.publicKey).toBeInstanceOf(Uint8Array); expect(args.publicKey.length).toBe(32); expect(typeof args.timeZone).toBe("string"); }); test("a confirm-email-code invalid_request bounces back to step=email with an error", async () => { sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); confirmEmailCodeSpy.mockRejectedValueOnce( new AuthError("invalid_request", "code expired", 400), ); const Page = (await importLoginPage()).default; const ui = render(Page); await fireEvent.input(ui.getByTestId("login-email-input"), { target: { value: "pilot@example.com" }, }); await fireEvent.click(ui.getByTestId("login-email-submit")); await waitFor(() => ui.getByTestId("login-code-input")); await fireEvent.input(ui.getByTestId("login-code-input"), { target: { value: "00000" }, }); await fireEvent.click(ui.getByTestId("login-code-submit")); await waitFor(() => { expect(ui.queryByTestId("login-code-input")).toBeNull(); expect(ui.getByTestId("login-email-input")).toBeInTheDocument(); expect(ui.getByTestId("login-error")).toHaveTextContent( /expired|already used/i, ); }); }); test("resend re-issues sendEmailCode and clears the code field", async () => { sendEmailCodeSpy .mockResolvedValueOnce({ challengeId: "ch-1" }) .mockResolvedValueOnce({ challengeId: "ch-2" }); const Page = (await importLoginPage()).default; const ui = render(Page); await fireEvent.input(ui.getByTestId("login-email-input"), { target: { value: "pilot@example.com" }, }); await fireEvent.click(ui.getByTestId("login-email-submit")); await waitFor(() => ui.getByTestId("login-code-input")); await fireEvent.input(ui.getByTestId("login-code-input"), { target: { value: "999999" }, }); await fireEvent.click(ui.getByTestId("login-resend")); await waitFor(() => { expect(sendEmailCodeSpy).toHaveBeenCalledTimes(2); expect( (ui.getByTestId("login-code-input") as HTMLInputElement).value, ).toBe(""); }); }); test("change-email returns to the email step", async () => { sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); const Page = (await importLoginPage()).default; const ui = render(Page); await fireEvent.input(ui.getByTestId("login-email-input"), { target: { value: "pilot@example.com" }, }); await fireEvent.click(ui.getByTestId("login-email-submit")); await waitFor(() => ui.getByTestId("login-code-input")); await fireEvent.click(ui.getByTestId("login-change-email")); await waitFor(() => { expect(ui.queryByTestId("login-code-input")).toBeNull(); expect(ui.getByTestId("login-email-input")).toBeInTheDocument(); }); }); test("renders the language picker with native names", async () => { const Page = (await importLoginPage()).default; const ui = render(Page); const select = ui.getByTestId( "login-language-select", ) as HTMLSelectElement; const options = Array.from(select.options).map((o) => ({ value: o.value, text: o.textContent?.trim() ?? "", })); expect(options).toEqual([ { value: "en", text: "English" }, { value: "ru", text: "Русский" }, ]); expect(select.value).toBe("en"); }); test("switching the language re-renders the form text in place", async () => { const Page = (await importLoginPage()).default; const ui = render(Page); expect(ui.getByTestId("login-email-submit")).toHaveTextContent( "send code", ); const select = ui.getByTestId( "login-language-select", ) as HTMLSelectElement; await fireEvent.change(select, { target: { value: "ru" } }); await waitFor(() => { expect(ui.getByTestId("login-email-submit")).toHaveTextContent( "отправить код", ); }); expect(i18n.locale).toBe("ru"); }); test("sendEmailCode receives the active locale", async () => { sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); const Page = (await importLoginPage()).default; const ui = render(Page); const select = ui.getByTestId( "login-language-select", ) as HTMLSelectElement; await fireEvent.change(select, { target: { value: "ru" } }); await fireEvent.input(ui.getByTestId("login-email-input"), { target: { value: "pilot@example.com" }, }); await fireEvent.click(ui.getByTestId("login-email-submit")); await waitFor(() => { expect(sendEmailCodeSpy).toHaveBeenCalledTimes(1); }); const args = sendEmailCodeSpy.mock.calls[0]!; expect(args[1]).toBe("pilot@example.com"); expect(args[2]).toEqual({ locale: "ru" }); }); });