dashboard-logs-export-progress-ui.test.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /**
  2. * @vitest-environment happy-dom
  3. */
  4. import type { ReactNode } from "react";
  5. import { act } from "react";
  6. import { createRoot } from "react-dom/client";
  7. import { NextIntlClientProvider } from "next-intl";
  8. import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
  9. import { UsageLogsFilters } from "@/app/[locale]/dashboard/logs/_components/usage-logs-filters";
  10. import dashboardMessages from "../../messages/en/dashboard.json";
  11. const originalCreateObjectURL = globalThis.URL.createObjectURL;
  12. const originalRevokeObjectURL = globalThis.URL.revokeObjectURL;
  13. const originalAnchorClick = HTMLAnchorElement.prototype.click;
  14. const {
  15. downloadUsageLogsExportMock,
  16. getUsageLogsExportStatusMock,
  17. startUsageLogsExportMock,
  18. toastErrorMock,
  19. toastSuccessMock,
  20. } = vi.hoisted(() => ({
  21. startUsageLogsExportMock: vi.fn(),
  22. getUsageLogsExportStatusMock: vi.fn(),
  23. downloadUsageLogsExportMock: vi.fn(),
  24. toastSuccessMock: vi.fn(),
  25. toastErrorMock: vi.fn(),
  26. }));
  27. vi.mock("@/actions/usage-logs", () => ({
  28. startUsageLogsExport: startUsageLogsExportMock,
  29. getUsageLogsExportStatus: getUsageLogsExportStatusMock,
  30. downloadUsageLogsExport: downloadUsageLogsExportMock,
  31. }));
  32. vi.mock("sonner", () => ({
  33. toast: {
  34. success: toastSuccessMock,
  35. error: toastErrorMock,
  36. },
  37. }));
  38. vi.mock("@/app/[locale]/dashboard/logs/_components/filters/active-filters-display", () => ({
  39. ActiveFiltersDisplay: () => <div data-testid="active-filters-display" />,
  40. }));
  41. vi.mock("@/app/[locale]/dashboard/logs/_components/filters/filter-section", () => ({
  42. FilterSection: ({ children }: { children: ReactNode }) => <div>{children}</div>,
  43. }));
  44. vi.mock("@/app/[locale]/dashboard/logs/_components/filters/identity-filters", () => ({
  45. IdentityFilters: () => <div data-testid="identity-filters" />,
  46. }));
  47. vi.mock("@/app/[locale]/dashboard/logs/_components/filters/quick-filters-bar", () => ({
  48. QuickFiltersBar: () => <div data-testid="quick-filters-bar" />,
  49. }));
  50. vi.mock("@/app/[locale]/dashboard/logs/_components/filters/request-filters", () => ({
  51. RequestFilters: ({
  52. onFiltersChange,
  53. }: {
  54. onFiltersChange: (filters: Record<string, unknown>) => void;
  55. }) => (
  56. <button
  57. type="button"
  58. data-testid="request-filters"
  59. onClick={() => onFiltersChange({ sessionId: "draft-session" })}
  60. >
  61. Draft Request Filters
  62. </button>
  63. ),
  64. }));
  65. vi.mock("@/app/[locale]/dashboard/logs/_components/filters/status-filters", () => ({
  66. StatusFilters: () => <div data-testid="status-filters" />,
  67. }));
  68. vi.mock("@/app/[locale]/dashboard/logs/_components/filters/time-filters", () => ({
  69. TimeFilters: () => <div data-testid="time-filters" />,
  70. }));
  71. function renderWithIntl(node: ReactNode) {
  72. const container = document.createElement("div");
  73. document.body.appendChild(container);
  74. const root = createRoot(container);
  75. act(() => {
  76. root.render(
  77. <NextIntlClientProvider
  78. locale="en"
  79. messages={{ dashboard: dashboardMessages }}
  80. timeZone="UTC"
  81. >
  82. {node}
  83. </NextIntlClientProvider>
  84. );
  85. });
  86. return {
  87. container,
  88. unmount: () => {
  89. act(() => root.unmount());
  90. container.remove();
  91. },
  92. };
  93. }
  94. async function actClick(el: Element | null) {
  95. if (!el) throw new Error("element not found");
  96. await act(async () => {
  97. el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  98. });
  99. }
  100. async function flushPromises() {
  101. await act(async () => {
  102. await Promise.resolve();
  103. await Promise.resolve();
  104. });
  105. }
  106. describe("UsageLogsFilters export progress UI", () => {
  107. beforeEach(() => {
  108. vi.clearAllMocks();
  109. vi.useFakeTimers();
  110. globalThis.URL.createObjectURL = vi.fn(() => "blob:usage-logs");
  111. globalThis.URL.revokeObjectURL = vi.fn();
  112. HTMLAnchorElement.prototype.click = vi.fn();
  113. });
  114. afterEach(() => {
  115. vi.useRealTimers();
  116. vi.restoreAllMocks();
  117. globalThis.URL.createObjectURL = originalCreateObjectURL;
  118. globalThis.URL.revokeObjectURL = originalRevokeObjectURL;
  119. HTMLAnchorElement.prototype.click = originalAnchorClick;
  120. document.body.innerHTML = "";
  121. });
  122. test("shows export progress while polling and downloads when completed", async () => {
  123. startUsageLogsExportMock.mockResolvedValue({ ok: true, data: { jobId: "job-1" } });
  124. getUsageLogsExportStatusMock
  125. .mockResolvedValueOnce({
  126. ok: true,
  127. data: {
  128. jobId: "job-1",
  129. status: "running",
  130. processedRows: 50,
  131. totalRows: 200,
  132. progressPercent: 25,
  133. },
  134. })
  135. .mockResolvedValueOnce({
  136. ok: true,
  137. data: {
  138. jobId: "job-1",
  139. status: "completed",
  140. processedRows: 200,
  141. totalRows: 200,
  142. progressPercent: 100,
  143. },
  144. });
  145. downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" });
  146. const { container, unmount } = renderWithIntl(
  147. <UsageLogsFilters
  148. isAdmin={true}
  149. providers={[]}
  150. initialKeys={[]}
  151. filters={{}}
  152. onChange={() => {}}
  153. onReset={() => {}}
  154. />
  155. );
  156. const exportButton = Array.from(container.querySelectorAll("button")).find(
  157. (button) => (button.textContent || "").trim() === "Export"
  158. );
  159. await actClick(exportButton ?? null);
  160. await flushPromises();
  161. expect(container.textContent).toContain("Exported 50 / 200");
  162. expect(container.textContent).toContain("25%");
  163. await act(async () => {
  164. await vi.advanceTimersByTimeAsync(800);
  165. });
  166. await flushPromises();
  167. expect(downloadUsageLogsExportMock).toHaveBeenCalledWith("job-1");
  168. expect(toastSuccessMock).toHaveBeenCalledWith("Export completed successfully");
  169. expect(toastErrorMock).not.toHaveBeenCalled();
  170. unmount();
  171. });
  172. test("exports the applied filters instead of unapplied local draft filters", async () => {
  173. startUsageLogsExportMock.mockResolvedValue({ ok: true, data: { jobId: "job-2" } });
  174. getUsageLogsExportStatusMock.mockResolvedValueOnce({
  175. ok: true,
  176. data: {
  177. jobId: "job-2",
  178. status: "completed",
  179. processedRows: 1,
  180. totalRows: 1,
  181. progressPercent: 100,
  182. },
  183. });
  184. downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" });
  185. const { container, unmount } = renderWithIntl(
  186. <UsageLogsFilters
  187. isAdmin={true}
  188. providers={[]}
  189. initialKeys={[]}
  190. filters={{ sessionId: "applied-session" }}
  191. onChange={() => {}}
  192. onReset={() => {}}
  193. />
  194. );
  195. await actClick(container.querySelector("[data-testid='request-filters']"));
  196. const exportButton = Array.from(container.querySelectorAll("button")).find(
  197. (button) => (button.textContent || "").trim() === "Export"
  198. );
  199. await actClick(exportButton ?? null);
  200. await flushPromises();
  201. expect(startUsageLogsExportMock).toHaveBeenCalledWith({ sessionId: "draft-session" });
  202. unmount();
  203. });
  204. });