Files
2026-07-13 13:39:12 +08:00

342 lines
9.7 KiB
TypeScript

// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
if (values) return `${key}:${JSON.stringify(values)}`;
return key;
},
}));
// Mock shared components
vi.mock("@/shared/components", () => ({
Card: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", { className: "card" }, children),
Badge: ({ children, variant, title }: { children: React.ReactNode; variant?: string; title?: string }) =>
React.createElement("span", { "data-variant": variant, title }, children),
Button: ({
children,
onClick,
disabled,
loading,
variant,
size,
"data-testid": testId,
}: {
children?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
variant?: string;
size?: string;
"data-testid"?: string;
}) =>
React.createElement(
"button",
{
onClick,
disabled: disabled || loading,
"data-variant": variant,
"data-testid": testId,
},
children,
),
Input: ({
value,
onChange,
placeholder,
"data-testid": testId,
className,
onKeyDown,
}: {
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
"data-testid"?: string;
className?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}) =>
React.createElement("input", {
value,
onChange,
placeholder,
"data-testid": testId,
className,
onKeyDown,
}),
Select: ({
children,
value,
onChange,
}: {
children: React.ReactNode;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}) => React.createElement("select", { value, onChange }, children),
Modal: ({
isOpen,
title,
children,
footer,
onClose,
}: {
isOpen?: boolean;
title?: string;
children?: React.ReactNode;
footer?: React.ReactNode;
onClose?: () => void;
}) =>
isOpen
? React.createElement(
"div",
{ "data-testid": "modal", "data-title": title },
React.createElement("button", { onClick: onClose, "data-testid": "modal-close" }, "X"),
children,
footer,
)
: null,
}));
vi.mock(
"../../../src/app/(dashboard)/dashboard/memory/components/EditMemoryModal",
() => ({
default: ({
isOpen,
onClose,
}: {
isOpen: boolean;
memory: unknown;
onClose: () => void;
onSaved: () => void;
}) =>
isOpen
? React.createElement("div", { "data-testid": "edit-memory-modal" }, [
React.createElement("button", { key: "close", onClick: onClose }, "close"),
])
: null,
}),
);
const MOCK_MEMORIES = [
{
id: "mem-1",
apiKeyId: "key-1",
sessionId: null,
type: "factual",
key: "user.name",
content: "Alice",
metadata: {},
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
expiresAt: null,
},
{
id: "mem-2",
apiKeyId: "key-1",
sessionId: null,
type: "episodic",
key: "event.meeting",
content: "Had a meeting",
metadata: {},
createdAt: "2026-01-02T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
expiresAt: null,
},
];
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
describe("MemoriesTab", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: MOCK_MEMORIES,
total: 2,
totalPages: 1,
stats: {
total: 2,
tokensUsed: 150,
hitRate: 0.75,
cacheStats: { hits: 3, misses: 1 },
},
}),
});
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renders memories after fetch", async () => {
const { default: MemoriesTab } = await import(
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<MemoriesTab />);
});
// Wait for the 300ms debounce
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(container.textContent).toContain("user.name");
expect(container.textContent).toContain("Alice");
});
it("shows hit rate card when cacheStats.hits + misses > 0", async () => {
const { default: MemoriesTab } = await import(
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<MemoriesTab />);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
// hitRate is shown since cacheStats.hits=3, misses=1
expect(container.textContent).toContain("hitRate");
});
it("does not show hit rate when cacheStats is 0/0", async () => {
vi.resetModules();
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: MOCK_MEMORIES,
total: 2,
totalPages: 1,
stats: {
total: 2,
tokensUsed: 0,
hitRate: 0,
cacheStats: { hits: 0, misses: 0 },
},
}),
});
const { default: MemoriesTab } = await import(
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<MemoriesTab />);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
// hitRate card should NOT appear
expect(container.querySelector("[data-testid='hit-rate-card']")).toBeNull();
});
it("opens edit modal when pencil button is clicked", async () => {
const { default: MemoriesTab } = await import(
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<MemoriesTab />);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
const editBtn = container.querySelector("[data-testid='edit-memory-mem-1']") as HTMLButtonElement | null;
expect(editBtn).toBeTruthy();
await act(async () => {
editBtn?.click();
});
expect(container.querySelector("[data-testid='edit-memory-modal']")).toBeTruthy();
});
it("shows empty state when no memories returned", async () => {
vi.resetModules();
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [],
total: 0,
totalPages: 1,
stats: { total: 0, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
}),
});
const { default: MemoriesTab } = await import(
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<MemoriesTab />);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(container.querySelector("[data-testid='memories-empty-state']")).toBeTruthy();
});
it("calls DELETE when delete confirmed", async () => {
const mockFetch = vi.fn();
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: MOCK_MEMORIES,
total: 2,
totalPages: 1,
stats: { total: 2, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
}),
});
mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
globalThis.fetch = mockFetch;
const { default: MemoriesTab } = await import(
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<MemoriesTab />);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
const deleteBtn = container.querySelector("[data-testid='delete-memory-mem-1']") as HTMLButtonElement | null;
expect(deleteBtn).toBeTruthy();
await act(async () => {
deleteBtn?.click();
});
// Confirm modal shown
const modal = container.querySelector("[data-testid='modal']");
expect(modal).toBeTruthy();
// Find danger button inside modal
const dangerBtns = Array.from(container.querySelectorAll("button")).filter(
(b) => b.getAttribute("data-variant") === "danger",
);
expect(dangerBtns.length).toBeGreaterThan(0);
await act(async () => {
dangerBtns[0].click();
});
// DELETE should have been called
const deleteCalls = (mockFetch as ReturnType<typeof vi.fn>).mock.calls.filter(
(c: [string, ...unknown[]]) =>
typeof c[0] === "string" && c[0].includes("mem-1") && c[1] && (c[1] as { method: string }).method === "DELETE",
);
expect(deleteCalls.length).toBeGreaterThan(0);
});
});