login-loading-state.test.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
  2. import { createRoot } from "react-dom/client";
  3. import { act } from "react";
  4. import LoginPage from "@/app/[locale]/login/page";
  5. const mockPush = vi.hoisted(() => vi.fn());
  6. const mockRefresh = vi.hoisted(() => vi.fn());
  7. const mockUseRouter = vi.hoisted(() => vi.fn(() => ({ push: mockPush, refresh: mockRefresh })));
  8. const mockUseSearchParams = vi.hoisted(() => vi.fn(() => ({ get: vi.fn(() => null) })));
  9. const mockUseTranslations = vi.hoisted(() => vi.fn(() => (key: string) => `t:${key}`));
  10. const mockUseLocale = vi.hoisted(() => vi.fn(() => "en"));
  11. const mockUsePathname = vi.hoisted(() => vi.fn(() => "/login"));
  12. vi.mock("next/navigation", () => ({
  13. useSearchParams: mockUseSearchParams,
  14. useRouter: mockUseRouter,
  15. usePathname: mockUsePathname,
  16. }));
  17. vi.mock("next-intl", () => ({
  18. useTranslations: mockUseTranslations,
  19. useLocale: mockUseLocale,
  20. }));
  21. vi.mock("@/i18n/routing", () => ({
  22. Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
  23. useRouter: mockUseRouter,
  24. usePathname: mockUsePathname,
  25. }));
  26. vi.mock("next-themes", () => ({
  27. useTheme: vi.fn(() => ({ theme: "system", setTheme: vi.fn() })),
  28. }));
  29. const globalFetch = global.fetch;
  30. describe("LoginPage Loading State", () => {
  31. let container: HTMLDivElement;
  32. let root: ReturnType<typeof createRoot>;
  33. beforeEach(() => {
  34. container = document.createElement("div");
  35. document.body.appendChild(container);
  36. root = createRoot(container);
  37. vi.clearAllMocks();
  38. global.fetch = vi.fn().mockResolvedValue({
  39. ok: true,
  40. json: async () => ({}),
  41. });
  42. });
  43. afterEach(() => {
  44. act(() => {
  45. root.unmount();
  46. });
  47. document.body.removeChild(container);
  48. global.fetch = globalFetch;
  49. });
  50. const render = async () => {
  51. await act(async () => {
  52. root.render(<LoginPage />);
  53. });
  54. };
  55. const setInputValue = (input: HTMLInputElement, value: string) => {
  56. const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
  57. window.HTMLInputElement.prototype,
  58. "value"
  59. )?.set;
  60. if (nativeInputValueSetter) {
  61. nativeInputValueSetter.call(input, value);
  62. } else {
  63. input.value = value;
  64. }
  65. input.dispatchEvent(new Event("input", { bubbles: true }));
  66. };
  67. const getSubmitButton = () =>
  68. container.querySelector('button[type="submit"]') as HTMLButtonElement;
  69. const getApiKeyInput = () => container.querySelector("input#apiKey") as HTMLInputElement;
  70. const getOverlay = () => container.querySelector('[data-testid="loading-overlay"]');
  71. it("starts in idle state with no overlay", async () => {
  72. await render();
  73. expect(getOverlay()).toBeNull();
  74. expect(getSubmitButton().disabled).toBe(true);
  75. expect(getApiKeyInput().disabled).toBe(false);
  76. });
  77. it("shows fullscreen overlay during submission", async () => {
  78. let resolveFetch: (value: any) => void;
  79. const fetchPromise = new Promise((resolve) => {
  80. resolveFetch = resolve;
  81. });
  82. (global.fetch as any).mockReturnValue(fetchPromise);
  83. await render();
  84. const input = getApiKeyInput();
  85. await act(async () => {
  86. setInputValue(input, "test-api-key");
  87. });
  88. const button = getSubmitButton();
  89. await act(async () => {
  90. button.click();
  91. });
  92. const overlay = getOverlay();
  93. expect(overlay).not.toBeNull();
  94. expect(overlay?.textContent).toContain("t:login.loggingIn");
  95. expect(getSubmitButton().disabled).toBe(true);
  96. expect(getApiKeyInput().disabled).toBe(true);
  97. await act(async () => {
  98. resolveFetch!({
  99. ok: true,
  100. json: async () => ({ redirectTo: "/dashboard" }),
  101. });
  102. });
  103. });
  104. it("keeps overlay on success until redirect", async () => {
  105. (global.fetch as any).mockResolvedValue({
  106. ok: true,
  107. json: async () => ({ redirectTo: "/dashboard" }),
  108. });
  109. await render();
  110. const input = getApiKeyInput();
  111. await act(async () => {
  112. setInputValue(input, "test-api-key");
  113. });
  114. await act(async () => {
  115. getSubmitButton().click();
  116. });
  117. const overlay = getOverlay();
  118. expect(overlay).not.toBeNull();
  119. expect(mockPush).toHaveBeenCalledWith("/dashboard");
  120. expect(mockRefresh).toHaveBeenCalled();
  121. });
  122. it("removes overlay and shows error on failure", async () => {
  123. (global.fetch as any).mockResolvedValue({
  124. ok: false,
  125. json: async () => ({ error: "Invalid key" }),
  126. });
  127. await render();
  128. const input = getApiKeyInput();
  129. await act(async () => {
  130. setInputValue(input, "test-api-key");
  131. });
  132. await act(async () => {
  133. getSubmitButton().click();
  134. });
  135. expect(getOverlay()).toBeNull();
  136. expect(container.textContent).toContain("Invalid key");
  137. expect(getSubmitButton().disabled).toBe(false);
  138. expect(getApiKeyInput().disabled).toBe(false);
  139. });
  140. it("removes overlay and shows error on network exception", async () => {
  141. (global.fetch as any).mockRejectedValue(new Error("Network error"));
  142. await render();
  143. const input = getApiKeyInput();
  144. await act(async () => {
  145. setInputValue(input, "test-api-key");
  146. });
  147. await act(async () => {
  148. getSubmitButton().click();
  149. });
  150. expect(getOverlay()).toBeNull();
  151. expect(container.textContent).toContain("t:errors.networkError");
  152. expect(getSubmitButton().disabled).toBe(false);
  153. });
  154. });