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, sessionLastActivity: {} as Record, 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 | ((state: typeof chatStateMock.state) => Record)) => { Object.assign(chatStateMock.state, typeof patch === 'function' ? patch(chatStateMock.state) : patch); }, }, })); function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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>(); 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(); }); });