200 lines
7.0 KiB
TypeScript
200 lines
7.0 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const hostApiFetchMock = vi.fn();
|
|
const chatStateMock = vi.hoisted(() => ({
|
|
state: {
|
|
currentSessionKey: 'agent:main:main',
|
|
currentAgentId: 'main',
|
|
sessions: [] as Array<{ key: string; displayName?: string; label?: string; updatedAt?: number }>,
|
|
messages: [] as Array<{ id?: string; role: string; content: unknown; timestamp?: number }>,
|
|
sessionLabels: {} as Record<string, string>,
|
|
sessionLastActivity: {} as Record<string, number>,
|
|
sending: false,
|
|
activeRunId: null as string | null,
|
|
activeRunSessionKey: null as string | null,
|
|
pendingFinal: false,
|
|
lastUserMessageAt: null as number | null,
|
|
error: null as string | null,
|
|
loadHistory: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/host-api', () => ({
|
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
|
}));
|
|
|
|
vi.mock('@/stores/chat', () => ({
|
|
useChatStore: {
|
|
getState: () => chatStateMock.state,
|
|
setState: (patch: Record<string, unknown> | ((state: typeof chatStateMock.state) => Record<string, unknown>)) => {
|
|
Object.assign(chatStateMock.state, typeof patch === 'function' ? patch(chatStateMock.state) : patch);
|
|
},
|
|
},
|
|
}));
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
describe('cron store fetchJobs dedupe', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
Object.assign(chatStateMock.state, {
|
|
currentSessionKey: 'agent:main:main',
|
|
currentAgentId: 'main',
|
|
sessions: [],
|
|
messages: [],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: false,
|
|
activeRunId: null,
|
|
activeRunSessionKey: null,
|
|
pendingFinal: false,
|
|
lastUserMessageAt: null,
|
|
error: null,
|
|
loadHistory: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
});
|
|
|
|
it('reuses in-flight fetchJobs request when called concurrently', async () => {
|
|
const listDeferred = deferred<Array<{ id: string }>>();
|
|
hostApiFetchMock.mockReturnValueOnce(listDeferred.promise);
|
|
|
|
const { useCronStore } = await import('@/stores/cron');
|
|
useCronStore.setState({ jobs: [], loading: false, error: null });
|
|
|
|
const first = useCronStore.getState().fetchJobs();
|
|
const second = useCronStore.getState().fetchJobs();
|
|
await Promise.resolve();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/cron/jobs');
|
|
|
|
listDeferred.resolve([{ id: 'job-1' }]);
|
|
await Promise.all([first, second]);
|
|
|
|
expect(useCronStore.getState().jobs.map((job) => job.id)).toEqual(['job-1']);
|
|
});
|
|
|
|
it('normalizes object-wrapped job lists', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({ jobs: [{ id: 'job-2' }] });
|
|
|
|
const { useCronStore } = await import('@/stores/cron');
|
|
useCronStore.setState({ jobs: [], loading: false, error: null });
|
|
|
|
await useCronStore.getState().fetchJobs();
|
|
|
|
expect(useCronStore.getState().jobs.map((job) => job.id)).toEqual(['job-2']);
|
|
expect(useCronStore.getState().error).toBeNull();
|
|
});
|
|
|
|
it('surfaces invalid job list responses without throwing map errors', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({ success: false, error: 'Gateway unavailable' });
|
|
|
|
const { useCronStore } = await import('@/stores/cron');
|
|
useCronStore.setState({ jobs: [], loading: false, error: null });
|
|
|
|
await useCronStore.getState().fetchJobs();
|
|
|
|
expect(useCronStore.getState().jobs).toEqual([]);
|
|
expect(useCronStore.getState().error).toContain('Gateway unavailable');
|
|
});
|
|
|
|
it('marks triggered cron sessions visible immediately', async () => {
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1773300000000);
|
|
hostApiFetchMock.mockImplementation(async (path: string) => {
|
|
if (path === '/api/cron/trigger') return { success: true };
|
|
if (path === '/api/cron/jobs') return { jobs: [] };
|
|
return { success: true };
|
|
});
|
|
|
|
const { useCronStore } = await import('@/stores/cron');
|
|
useCronStore.setState({
|
|
jobs: [{
|
|
id: 'job-1',
|
|
name: '每日经营日报',
|
|
message: '生成日报',
|
|
schedule: '0 9 * * *',
|
|
enabled: true,
|
|
createdAt: '2026-05-13T00:00:00.000Z',
|
|
updatedAt: '2026-05-13T00:00:00.000Z',
|
|
agentId: 'main',
|
|
}],
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
await useCronStore.getState().triggerJob('job-1');
|
|
|
|
expect(chatStateMock.state.sessions).toContainEqual(expect.objectContaining({
|
|
key: 'agent:main:cron:job-1',
|
|
label: '每日经营日报',
|
|
updatedAt: 1773300000000,
|
|
}));
|
|
expect(chatStateMock.state.sessionLabels['agent:main:cron:job-1']).toBe('每日经营日报');
|
|
expect(chatStateMock.state.sessionLastActivity['agent:main:cron:job-1']).toBe(1773300000000);
|
|
nowSpy.mockRestore();
|
|
});
|
|
|
|
it('shows the current cron query immediately and refreshes the fixed task conversation after trigger completion', async () => {
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1773300000000);
|
|
const triggerDeferred = deferred<{ success: boolean }>();
|
|
hostApiFetchMock.mockImplementation(async (path: string) => {
|
|
if (path === '/api/cron/trigger') return triggerDeferred.promise;
|
|
if (path === '/api/cron/jobs') return { jobs: [] };
|
|
return { success: true };
|
|
});
|
|
chatStateMock.state.currentSessionKey = 'agent:main:cron:job-1';
|
|
|
|
const { useCronStore } = await import('@/stores/cron');
|
|
useCronStore.setState({
|
|
jobs: [{
|
|
id: 'job-1',
|
|
name: '每日经营日报',
|
|
message: '生成日报',
|
|
schedule: '0 9 * * *',
|
|
enabled: true,
|
|
createdAt: '2026-05-13T00:00:00.000Z',
|
|
updatedAt: '2026-05-13T00:00:00.000Z',
|
|
agentId: 'main',
|
|
}],
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
const triggerPromise = useCronStore.getState().triggerJob('job-1');
|
|
await Promise.resolve();
|
|
|
|
expect(chatStateMock.state.sending).toBe(true);
|
|
expect(chatStateMock.state.activeRunSessionKey).toBe('agent:main:cron:job-1');
|
|
expect(chatStateMock.state.pendingFinal).toBe(true);
|
|
expect(chatStateMock.state.lastUserMessageAt).toBe(1773300000000);
|
|
expect(chatStateMock.state.messages).toContainEqual(expect.objectContaining({
|
|
role: 'user',
|
|
content: '生成日报',
|
|
timestamp: 1773300000000,
|
|
}));
|
|
expect(chatStateMock.state.messages).toContainEqual(expect.objectContaining({
|
|
role: 'assistant',
|
|
content: '任务已开始执行,完成后会自动在这里显示结果。',
|
|
}));
|
|
|
|
triggerDeferred.resolve({ success: true });
|
|
await triggerPromise;
|
|
|
|
expect(chatStateMock.state.loadHistory).toHaveBeenCalledWith(false);
|
|
expect(chatStateMock.state.sending).toBe(false);
|
|
expect(chatStateMock.state.activeRunSessionKey).toBeNull();
|
|
expect(chatStateMock.state.pendingFinal).toBe(false);
|
|
expect(chatStateMock.state.lastUserMessageAt).toBeNull();
|
|
nowSpy.mockRestore();
|
|
});
|
|
});
|