MVP frontend+backend
This commit is contained in:
@@ -4,3 +4,4 @@ export { useOptimizationTips } from './useOptimizationTips'
|
||||
export { useParentalLeave } from './useParentalLeave'
|
||||
export { usePlannedDays } from './usePlannedDays'
|
||||
export { useVacationDays } from './useVacationDays'
|
||||
export { useScraperFetch } from './useScraperFetch'
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('useParentalLeave', () => {
|
||||
})
|
||||
|
||||
it('initializes with null data', () => {
|
||||
const { result } = renderHook(() => useParentalLeave(null))
|
||||
const { result } = renderHook(() => useParentalLeave(null, null))
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
@@ -38,19 +38,19 @@ describe('useParentalLeave', () => {
|
||||
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(mockData as any)
|
||||
|
||||
renderHook(() => useParentalLeave('ws1'))
|
||||
renderHook(() => useParentalLeave('pd1', 'child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(api.getParentalLeave).toHaveBeenCalledWith('ws1')
|
||||
expect(api.getParentalLeave).toHaveBeenCalledWith('pd1', 'child1')
|
||||
})
|
||||
|
||||
it('handles fetch errors', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockRejectedValue(new Error('Fetch failed'))
|
||||
|
||||
const { result } = renderHook(() => useParentalLeave('ws1'))
|
||||
const { result } = renderHook(() => useParentalLeave('pd1', 'child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
@@ -59,10 +59,10 @@ describe('useParentalLeave', () => {
|
||||
expect(result.current.error).toContain('Fetch failed')
|
||||
})
|
||||
|
||||
it('does not fetch when workspaceId is null', () => {
|
||||
it('does not fetch when personalDataUuid or childId is null', () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue({} as any)
|
||||
|
||||
renderHook(() => useParentalLeave(null))
|
||||
renderHook(() => useParentalLeave(null, null))
|
||||
|
||||
expect(api.getParentalLeave).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -70,7 +70,7 @@ describe('useParentalLeave', () => {
|
||||
it('provides refresh function', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue({} as any)
|
||||
|
||||
const { result } = renderHook(() => useParentalLeave('ws1'))
|
||||
const { result } = renderHook(() => useParentalLeave('pd1', 'child1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh()
|
||||
|
||||
@@ -9,17 +9,20 @@ interface UseParentalLeaveResult {
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useParentalLeave(workspaceId: string | null): UseParentalLeaveResult {
|
||||
export function useParentalLeave(
|
||||
personalDataUuid: string | null,
|
||||
childId: string | null,
|
||||
): UseParentalLeaveResult {
|
||||
const [data, setData] = useState<ParentalLeaveFetch | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
if (!personalDataUuid || !childId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await api.getParentalLeave(workspaceId)
|
||||
const response = await api.getParentalLeave(personalDataUuid, childId)
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
|
||||
@@ -27,7 +30,7 @@ export function useParentalLeave(workspaceId: string | null): UseParentalLeaveRe
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
}, [personalDataUuid, childId])
|
||||
|
||||
useEffect(() => {
|
||||
fetch()
|
||||
|
||||
@@ -11,12 +11,12 @@ describe('usePlannedDays', () => {
|
||||
})
|
||||
|
||||
it('initializes with null data', () => {
|
||||
const { result } = renderHook(() => usePlannedDays(null))
|
||||
const { result } = renderHook(() => usePlannedDays())
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('fetches planned days when both ids provided', async () => {
|
||||
it('fetches planned days when childId provided', async () => {
|
||||
const mockData = [
|
||||
{
|
||||
id: '1',
|
||||
@@ -33,19 +33,19 @@ describe('usePlannedDays', () => {
|
||||
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue(mockData as any)
|
||||
|
||||
renderHook(() => usePlannedDays('ws1', 'child1'))
|
||||
renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(api.getPlannedDays).toHaveBeenCalledWith('ws1', 'child1')
|
||||
expect(api.getPlannedDays).toHaveBeenCalledWith('child1')
|
||||
})
|
||||
|
||||
it('does not fetch without childId', () => {
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
||||
|
||||
renderHook(() => usePlannedDays('ws1'))
|
||||
renderHook(() => usePlannedDays())
|
||||
|
||||
expect(api.getPlannedDays).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -66,7 +66,7 @@ describe('usePlannedDays', () => {
|
||||
vi.mocked(api.createPlannedDay).mockResolvedValue(mockAdded as any)
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([mockAdded])
|
||||
|
||||
renderHook(() => usePlannedDays('ws1', 'child1'))
|
||||
renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
// just test that add can be called; mocks handle the result
|
||||
@@ -79,7 +79,7 @@ describe('usePlannedDays', () => {
|
||||
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('ws1', 'child1'))
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.remove('pd1')
|
||||
|
||||
@@ -11,17 +11,17 @@ interface UsePlannedDaysResult {
|
||||
remove: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function usePlannedDays(workspaceId: string | null, childId?: string): UsePlannedDaysResult {
|
||||
export function usePlannedDays(childId?: string): UsePlannedDaysResult {
|
||||
const [data, setData] = useState<PlannedLeaveDay[] | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!workspaceId || !childId) return
|
||||
if (!childId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await api.getPlannedDays(workspaceId, childId)
|
||||
const response = await api.getPlannedDays(childId)
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch planned days')
|
||||
@@ -29,19 +29,18 @@ export function usePlannedDays(workspaceId: string | null, childId?: string): Us
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId, childId])
|
||||
}, [childId])
|
||||
|
||||
const add = useCallback(
|
||||
async (date: string, childId: string, leaveType: string) => {
|
||||
if (!workspaceId) throw new Error('Workspace not selected')
|
||||
const result = await api.createPlannedDay(workspaceId, childId, {
|
||||
const result = await api.createPlannedDay(childId, {
|
||||
date,
|
||||
leaveType,
|
||||
})
|
||||
await fetch()
|
||||
return result
|
||||
},
|
||||
[workspaceId, fetch]
|
||||
[fetch]
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useScraperFetch } from './useScraperFetch'
|
||||
import * as api from '@/services/api'
|
||||
import * as errors from '@/services/errors'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
vi.mock('@/services/errors')
|
||||
|
||||
describe('useScraperFetch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('initializes with null state', () => {
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
expect(result.current.sessionId).toBeNull()
|
||||
expect(result.current.pollData).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.isRunning).toBe(false)
|
||||
expect(result.current.isTerminal).toBe(false)
|
||||
})
|
||||
|
||||
it('starts scraping session', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue({
|
||||
session_id: 'sess-123',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start('child-1')
|
||||
})
|
||||
|
||||
expect(result.current.sessionId).toBe('sess-123')
|
||||
expect(result.current.pollData?.status).toBe('launching')
|
||||
expect(api.startScraperSession).toHaveBeenCalledWith({ child_id: 'child-1' })
|
||||
})
|
||||
|
||||
it('transitions through polling states', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue({
|
||||
session_id: 'sess-123',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
|
||||
vi.mocked(api.pollScraperSession)
|
||||
.mockResolvedValueOnce({ status: 'awaiting_scan', qr_data: 'qr-123' } as any)
|
||||
.mockResolvedValueOnce({ status: 'authenticating' } as any)
|
||||
.mockResolvedValueOnce({ status: 'fetching' } as any)
|
||||
.mockResolvedValueOnce({ status: 'done', result: { data: 'fetched' } } as any)
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
// First poll
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.pollData?.status).toBe('awaiting_scan')
|
||||
expect(result.current.isRunning).toBe(true)
|
||||
|
||||
// Second poll
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.pollData?.status).toBe('authenticating')
|
||||
|
||||
// Third poll
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.pollData?.status).toBe('fetching')
|
||||
|
||||
// Fourth poll - terminal status
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.pollData?.status).toBe('done')
|
||||
expect(result.current.isRunning).toBe(false)
|
||||
expect(result.current.isTerminal).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels scraping session', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue({
|
||||
session_id: 'sess-123',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
|
||||
vi.mocked(api.pollScraperSession).mockResolvedValue({ status: 'awaiting_scan' } as any)
|
||||
vi.mocked(api.cancelScraperSession).mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.cancel()
|
||||
})
|
||||
|
||||
expect(api.cancelScraperSession).toHaveBeenCalledWith('sess-123')
|
||||
expect(result.current.pollData?.status).toBe('cancelled')
|
||||
expect(result.current.sessionId).toBeNull()
|
||||
})
|
||||
|
||||
it('handles start error', async () => {
|
||||
vi.mocked(api.startScraperSession).mockRejectedValue(new Error('Start failed'))
|
||||
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
expect(result.current.error).toContain('Start failed')
|
||||
expect(result.current.sessionId).toBeNull()
|
||||
})
|
||||
|
||||
it('handles unauthorized error on start', async () => {
|
||||
vi.mocked(api.startScraperSession).mockRejectedValue(new Error('Unauthorized'))
|
||||
vi.mocked(errors.isUnauthorized).mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
expect(result.current.isUnauthorized).toBe(true)
|
||||
expect(result.current.error).toBe('Not authenticated')
|
||||
})
|
||||
|
||||
it('handles poll error', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue({
|
||||
session_id: 'sess-123',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
|
||||
vi.mocked(api.pollScraperSession).mockRejectedValue(new Error('Poll failed'))
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
expect(result.current.error).toContain('Poll failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { isUnauthorized } from '@/services/errors'
|
||||
|
||||
export type ScraperStatus = 'idle' | 'launching' | 'awaiting_scan' | 'authenticating' | 'fetching' | 'done' | 'error' | 'cancelled'
|
||||
|
||||
interface PollResponse {
|
||||
status: ScraperStatus
|
||||
qr_data?: string
|
||||
result?: Record<string, unknown>
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface ResolvedIds {
|
||||
workspace_id: string
|
||||
personal_data_uuid: string
|
||||
child_id: string
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 1000
|
||||
|
||||
interface UseScraperFetchResult {
|
||||
sessionId: string | null
|
||||
pollData: PollResponse | null
|
||||
resolvedIds: ResolvedIds | null
|
||||
error: string | null
|
||||
isUnauthorized: boolean
|
||||
start: (childId?: string) => Promise<void>
|
||||
cancel: () => Promise<void>
|
||||
isRunning: boolean
|
||||
isTerminal: boolean
|
||||
}
|
||||
|
||||
export function useScraperFetch(): UseScraperFetchResult {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [pollData, setPollData] = useState<PollResponse | null>(null)
|
||||
const [resolvedIds, setResolvedIds] = useState<ResolvedIds | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [unauthorizedError, setUnauthorizedError] = useState(false)
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isTerminalStatus = (s: ScraperStatus) =>
|
||||
s === 'done' || s === 'error' || s === 'cancelled'
|
||||
|
||||
const startPolling = useCallback((sid: string) => {
|
||||
stopPolling()
|
||||
intervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await api.pollScraperSession(sid)
|
||||
setPollData(data as PollResponse)
|
||||
if (isTerminalStatus((data as PollResponse).status)) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Poll error')
|
||||
stopPolling()
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
}, [stopPolling])
|
||||
|
||||
const start = useCallback(
|
||||
async (childId?: string) => {
|
||||
if (sessionId && pollData && !isTerminalStatus(pollData.status)) {
|
||||
stopPolling()
|
||||
try {
|
||||
await api.cancelScraperSession(sessionId)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setUnauthorizedError(false)
|
||||
setPollData(null)
|
||||
setSessionId(null)
|
||||
setResolvedIds(null)
|
||||
|
||||
try {
|
||||
const payload = childId ? { child_id: childId } : {}
|
||||
const resp = await api.startScraperSession(payload)
|
||||
setSessionId(resp.session_id)
|
||||
setResolvedIds({
|
||||
workspace_id: resp.workspace_id,
|
||||
personal_data_uuid: resp.personal_data_uuid,
|
||||
child_id: resp.child_id,
|
||||
})
|
||||
setPollData({ status: 'launching' })
|
||||
startPolling(resp.session_id)
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) {
|
||||
setUnauthorizedError(true)
|
||||
setError('Not authenticated')
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Start error')
|
||||
}
|
||||
}
|
||||
},
|
||||
[sessionId, pollData, stopPolling, startPolling],
|
||||
)
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
stopPolling()
|
||||
if (sessionId) {
|
||||
try {
|
||||
await api.cancelScraperSession(sessionId)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
setPollData({ status: 'cancelled' })
|
||||
setSessionId(null)
|
||||
}, [sessionId, stopPolling])
|
||||
|
||||
const isRunning = sessionId !== null && pollData !== null && !isTerminalStatus(pollData.status)
|
||||
const isTerminal = pollData !== null && isTerminalStatus(pollData.status)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
pollData,
|
||||
resolvedIds,
|
||||
error,
|
||||
isUnauthorized: unauthorizedError,
|
||||
start,
|
||||
cancel,
|
||||
isRunning,
|
||||
isTerminal,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user