- Fix useActivityLog hook to properly load activity log entries from the store - Add comprehensive test coverage for useActivityLog hook with 243 lines of tests - Restore missing ActivityLogModal CSS styles (modal overlay, list, animations) - Add ActivityLogModal component tests for empty state, loading, and error handling - Register activity log route in App.tsx - Update README with activity log data source documentation - Add changeset for patch release
281 lines
7.9 KiB
TypeScript
281 lines
7.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
import { useActivityLog } from "./useActivityLog";
|
|
import * as apiModule from "../api";
|
|
import type { ActivityFeedEntry } from "../api";
|
|
|
|
// Mock the API module
|
|
vi.mock("../api", () => ({
|
|
fetchActivityFeed: vi.fn(),
|
|
fetchActivityLog: vi.fn(),
|
|
}));
|
|
|
|
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
|
|
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
|
|
|
|
/** Create ActivityFeedEntry[] entries (unified feed format) */
|
|
function createFeedEntries(
|
|
count: number,
|
|
projectId = "proj_123",
|
|
projectName = "Test Project",
|
|
): ActivityFeedEntry[] {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
id: `feed_entry_${i}`,
|
|
timestamp: new Date(Date.now() - i * 60000).toISOString(),
|
|
type: "task:created" as const,
|
|
projectId,
|
|
projectName,
|
|
taskId: "FN-001",
|
|
taskTitle: "Test Task",
|
|
details: "Task created",
|
|
}));
|
|
}
|
|
|
|
describe("useActivityLog", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
// Default: both mocks return empty arrays
|
|
mockFetchActivityFeed.mockResolvedValue([]);
|
|
mockFetchActivityLog.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// ── Single-project mode (default) ─────────────────────────────────
|
|
|
|
it("initializes with empty entries and loads on mount", async () => {
|
|
mockFetchActivityLog.mockResolvedValue([]);
|
|
|
|
const { result } = renderHook(() => useActivityLog());
|
|
|
|
expect(result.current.loading).toBe(true);
|
|
expect(result.current.entries).toEqual([]);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.entries).toEqual([]);
|
|
// Should use per-project log, not unified feed
|
|
expect(mockFetchActivityLog).toHaveBeenCalled();
|
|
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetches entries from per-project log in single-project mode", async () => {
|
|
const mockEntries = createFeedEntries(1);
|
|
mockFetchActivityLog.mockResolvedValue(
|
|
mockEntries.map((e) => ({
|
|
id: e.id,
|
|
timestamp: e.timestamp,
|
|
type: e.type,
|
|
taskId: e.taskId,
|
|
taskTitle: e.taskTitle,
|
|
details: e.details,
|
|
metadata: e.metadata,
|
|
})),
|
|
);
|
|
|
|
const { result } = renderHook(() => useActivityLog());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.entries).toHaveLength(1);
|
|
});
|
|
|
|
// Hook converts ActivityLogEntry to ActivityFeedEntry with empty project fields
|
|
expect(result.current.entries[0].type).toBe("task:created");
|
|
expect(mockFetchActivityLog).toHaveBeenCalled();
|
|
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("filters by type via per-project log", async () => {
|
|
mockFetchActivityLog.mockResolvedValue([]);
|
|
|
|
renderHook(() => useActivityLog({ type: "task:created" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: "task:created" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("respects custom limit via per-project log", async () => {
|
|
mockFetchActivityLog.mockResolvedValue([]);
|
|
|
|
renderHook(() => useActivityLog({ limit: 100 }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
|
expect.objectContaining({ limit: 100 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("does not auto-refresh when disabled", async () => {
|
|
mockFetchActivityLog.mockResolvedValue([]);
|
|
|
|
renderHook(() => useActivityLog({ autoRefresh: false }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Advance time — should not trigger another fetch
|
|
vi.useRealTimers();
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
|
|
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refresh function manually refreshes data", async () => {
|
|
mockFetchActivityLog.mockResolvedValue([]);
|
|
|
|
const { result } = renderHook(() => useActivityLog({ autoRefresh: false }));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.refresh();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
it("clear removes all entries", async () => {
|
|
const mockEntries = createFeedEntries(1);
|
|
mockFetchActivityLog.mockResolvedValue(
|
|
mockEntries.map((e) => ({
|
|
id: e.id,
|
|
timestamp: e.timestamp,
|
|
type: e.type,
|
|
taskId: e.taskId,
|
|
taskTitle: e.taskTitle,
|
|
details: e.details,
|
|
})),
|
|
);
|
|
|
|
const { result } = renderHook(() => useActivityLog());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.entries).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.clear();
|
|
});
|
|
|
|
expect(result.current.entries).toEqual([]);
|
|
expect(result.current.hasMore).toBe(false);
|
|
});
|
|
|
|
it("handles errors gracefully", async () => {
|
|
mockFetchActivityLog.mockRejectedValue(new Error("Server error"));
|
|
|
|
const { result } = renderHook(() => useActivityLog());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.error).not.toBeNull();
|
|
});
|
|
|
|
it("sets hasMore when entries equal limit", async () => {
|
|
const mockEntries = createFeedEntries(50);
|
|
mockFetchActivityLog.mockResolvedValue(
|
|
mockEntries.map((e) => ({
|
|
id: e.id,
|
|
timestamp: e.timestamp,
|
|
type: e.type,
|
|
taskId: e.taskId,
|
|
taskTitle: e.taskTitle,
|
|
details: e.details,
|
|
})),
|
|
);
|
|
|
|
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.entries).toHaveLength(50);
|
|
});
|
|
|
|
expect(result.current.hasMore).toBe(true);
|
|
});
|
|
|
|
it("sets hasMore to false when fewer entries than limit", async () => {
|
|
const mockEntries = createFeedEntries(30);
|
|
mockFetchActivityLog.mockResolvedValue(
|
|
mockEntries.map((e) => ({
|
|
id: e.id,
|
|
timestamp: e.timestamp,
|
|
type: e.type,
|
|
taskId: e.taskId,
|
|
taskTitle: e.taskTitle,
|
|
details: e.details,
|
|
})),
|
|
);
|
|
|
|
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.entries).toHaveLength(30);
|
|
});
|
|
|
|
expect(result.current.hasMore).toBe(false);
|
|
});
|
|
|
|
// ── Multi-project mode (useCentralFeed) ───────────────────────────
|
|
|
|
it("fetches from unified feed when useCentralFeed is true", async () => {
|
|
const mockEntries = createFeedEntries(2, "proj_multi", "Multi Project");
|
|
mockFetchActivityFeed.mockResolvedValue(mockEntries);
|
|
|
|
const { result } = renderHook(() =>
|
|
useActivityLog({ useCentralFeed: true }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.entries).toHaveLength(2);
|
|
});
|
|
|
|
expect(result.current.entries[0].projectName).toBe("Multi Project");
|
|
expect(mockFetchActivityFeed).toHaveBeenCalled();
|
|
expect(mockFetchActivityLog).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes projectId to unified feed when useCentralFeed is true", async () => {
|
|
mockFetchActivityFeed.mockResolvedValue([]);
|
|
|
|
renderHook(() =>
|
|
useActivityLog({ projectId: "proj_456", useCentralFeed: true }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
|
expect.objectContaining({ projectId: "proj_456" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("passes type filter to unified feed when useCentralFeed is true", async () => {
|
|
mockFetchActivityFeed.mockResolvedValue([]);
|
|
|
|
renderHook(() =>
|
|
useActivityLog({ type: "task:failed", useCentralFeed: true }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: "task:failed" }),
|
|
);
|
|
});
|
|
});
|
|
});
|