Merge pull request #991 from nowcommunity/codex/create-implementation-plan-for-time-units-error

Handle time unit fetch errors
This commit is contained in:
Kari Lintulaakso
2025-11-27 14:30:54 +02:00
committed by GitHub
5 changed files with 236 additions and 13 deletions
@@ -0,0 +1,52 @@
import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'
import request from 'supertest'
import app from '../../app'
import * as timeUnitService from '../../services/timeUnit'
import { pool } from '../../utils/db'
import { login, resetDatabase, resetDatabaseTimeout, send } from '../utils'
describe('GET /time-unit endpoints', () => {
beforeAll(async () => {
await resetDatabase()
await login()
}, resetDatabaseTimeout)
afterAll(async () => {
await pool.end()
})
it('returns all time units with expected shape', async () => {
const { body, status } = await send<Record<string, unknown>[]>('time-unit/all', 'GET')
expect(status).toBe(200)
expect(Array.isArray(body)).toBe(true)
expect(body.length).toBeGreaterThan(0)
const [first] = body
expect(first).toHaveProperty('tu_name')
expect(first).toHaveProperty('tu_display_name')
expect(first).toHaveProperty('rank')
expect(first).toHaveProperty('seq_name')
expect(first).toHaveProperty('low_bound')
expect(first).toHaveProperty('up_bound')
})
it('returns structured 404 response for missing time unit', async () => {
const { body, status } = await send<{ message: string }>('time-unit/non-existent-id', 'GET')
expect(status).toBe(404)
expect(body).toEqual({ message: 'Time unit not found' })
})
it('handles unexpected failures gracefully', async () => {
const spy = jest.spyOn(timeUnitService, 'getAllTimeUnits').mockRejectedValueOnce(new Error('db unavailable'))
const response = await request(app).get('/time-unit/all')
expect(response.status).toBe(500)
expect(response.body).toEqual({ message: 'Failed to fetch time units' })
spy.mockRestore()
})
})
+13 -5
View File
@@ -22,17 +22,25 @@ import { writeTimeBound } from '../services/write/timeBound'
const router = Router()
router.get('/all', async (_req, res) => {
const time_units = await getAllTimeUnits()
return res.status(200).send(time_units)
try {
const time_units = await getAllTimeUnits()
return res.status(200).send(time_units)
} catch (_error) {
return res.status(500).send({ message: 'Failed to fetch time units' })
}
})
router.get('/sequences', getSequences)
router.get('/:id', async (req, res) => {
const id = req.params.id
const timeUnit = await getTimeUnitDetails(id)
if (!timeUnit) return res.status(404).send()
return res.status(200).send(timeUnit)
try {
const timeUnit = await getTimeUnitDetails(id)
if (!timeUnit) return res.status(404).send({ message: 'Time unit not found' })
return res.status(200).send(timeUnit)
} catch (_error) {
return res.status(500).send({ message: 'Failed to fetch time unit' })
}
})
router.get('/localities/:id', async (req, res) => {
+3 -3
View File
@@ -33,9 +33,9 @@ export const getAllTimeUnits = async () => {
tu_name: item.tu_name,
tu_display_name: item.tu_display_name,
rank: item.rank,
low_bound: item.now_tu_bound_now_time_unit_low_bndTonow_tu_bound.age,
up_bound: item.now_tu_bound_now_time_unit_up_bndTonow_tu_bound.age,
seq_name: item.now_tu_sequence.seq_name,
low_bound: item.now_tu_bound_now_time_unit_low_bndTonow_tu_bound?.age ?? null,
up_bound: item.now_tu_bound_now_time_unit_up_bndTonow_tu_bound?.age ?? null,
seq_name: item.now_tu_sequence?.seq_name ?? null,
}))
}
@@ -1,16 +1,38 @@
import { TimeUnit, TimeBoundDetailsType } from '@/shared/types'
import { useGetTimeBoundTimeUnitsQuery } from '@/redux/timeBoundReducer'
import { CircularProgress } from '@mui/material'
import { Alert, Button, CircularProgress, Stack } from '@mui/material'
import { skipToken } from '@reduxjs/toolkit/query'
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { MRT_ColumnDef } from 'material-react-table'
import { SimpleTable } from '@/components/DetailView/common/SimpleTable'
export const TimeUnitTab = () => {
const { data } = useDetailContext<TimeBoundDetailsType>()
const { data: timeUnitsData, isError } = useGetTimeBoundTimeUnitsQuery(data.bid)
const { data, mode } = useDetailContext<TimeBoundDetailsType>()
const boundId = typeof data?.bid === 'number' ? data.bid : null
const queryArg = !mode.new && boundId !== null ? boundId : skipToken
if (isError) return 'Error loading Time Units.'
if (!timeUnitsData) return <CircularProgress />
const { data: timeUnitsData, isError, isLoading, isFetching, refetch } = useGetTimeBoundTimeUnitsQuery(queryArg)
if (queryArg === skipToken)
return (
<Alert data-testid="time-units-disabled" severity="info">
Save this time bound to view related time units.
</Alert>
)
if (isError)
return (
<Stack spacing={2}>
<Alert data-testid="time-units-error" severity="error">
Could not load Time Units. Please try again.
</Alert>
<Button data-testid="time-units-retry" onClick={() => void refetch()} variant="outlined">
Retry
</Button>
</Stack>
)
if (isLoading || isFetching || !timeUnitsData) return <CircularProgress />
const columns: MRT_ColumnDef<TimeUnit>[] = [
{
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import { skipToken } from '@reduxjs/toolkit/query'
import { TimeUnitTab } from '@/components/TimeBound/Tabs/TimeUnitTab'
import { DetailContext, type DetailContextType, modeOptionToMode } from '@/components/DetailView/Context/DetailContext'
import type { EditDataType, TimeBoundDetailsType, TimeUnit } from '@/shared/types'
jest.mock('lodash-es', () => ({
cloneDeep: (value: unknown) => value,
}))
jest.mock('@/util/config', () => ({
BACKEND_URL: '',
ENABLE_WRITE: true,
ENV: 'test',
}))
jest.mock('@/components/DetailView/common/SimpleTable', () => ({
SimpleTable: ({ data }: { data: TimeUnit[] }) => (
<div data-testid="simple-table">
{data.map(unit => (
<div key={unit.tu_name}>{unit.tu_display_name}</div>
))}
</div>
),
}))
const mockGetTimeBoundTimeUnitsQuery = jest.fn()
jest.mock('@/redux/timeBoundReducer', () => ({
useGetTimeBoundTimeUnitsQuery: (...args: unknown[]) => mockGetTimeBoundTimeUnitsQuery(...args),
}))
const baseTimeBound: TimeBoundDetailsType = {
bid: 1,
b_name: 'Test Bound',
age: 12,
b_comment: '',
now_bau: [],
} as unknown as TimeBoundDetailsType
const createDetailContextValue = (overrides: Partial<DetailContextType<TimeBoundDetailsType>> = {}) =>
({
data: baseTimeBound,
mode: modeOptionToMode.read,
setMode: () => {},
editData: { bid: 1 } as unknown as EditDataType<TimeBoundDetailsType>,
setEditData: () => {},
textField: () => <></>,
bigTextField: () => <></>,
dropdown: () => <></>,
dropdownWithSearch: () => <></>,
radioSelection: () => <></>,
validator: () => ({ name: '', error: null }),
fieldsWithErrors: {},
setFieldsWithErrors: () => {},
...overrides,
}) as DetailContextType<TimeBoundDetailsType>
const renderTimeUnitTab = (overrides: Partial<DetailContextType<TimeBoundDetailsType>> = {}) => {
const contextValue = createDetailContextValue(overrides)
render(
<DetailContext.Provider value={contextValue as unknown as DetailContextType<unknown>}>
<TimeUnitTab />
</DetailContext.Provider>
)
}
beforeEach(() => {
jest.clearAllMocks()
mockGetTimeBoundTimeUnitsQuery.mockReturnValue({
data: [],
isError: false,
isLoading: false,
isFetching: false,
refetch: jest.fn(),
})
})
describe('Time Units tab', () => {
it('loads time units when bound id is present', async () => {
const timeUnits: TimeUnit[] = [
{
tu_name: 'TU1',
tu_display_name: 'Sample Unit',
seq_name: 'Sequence 1',
rank: 'Stage',
low_bound: 1,
up_bound: 1,
},
]
mockGetTimeBoundTimeUnitsQuery.mockReturnValue({
data: timeUnits,
isError: false,
isLoading: false,
isFetching: false,
refetch: jest.fn(),
})
renderTimeUnitTab()
expect(mockGetTimeBoundTimeUnitsQuery).toHaveBeenCalledWith(1)
const unitRow = await screen.findByText('Sample Unit')
expect(unitRow).toBeTruthy()
})
it('shows retry controls when loading time units fails', async () => {
const user = userEvent.setup()
const refetch = jest.fn()
mockGetTimeBoundTimeUnitsQuery.mockReturnValue({
data: undefined,
isError: true,
isLoading: false,
isFetching: false,
refetch,
})
renderTimeUnitTab()
const errorAlert = await screen.findByTestId('time-units-error')
expect(errorAlert).toBeTruthy()
await user.click(screen.getByTestId('time-units-retry'))
expect(refetch).toHaveBeenCalled()
})
it('displays guidance instead of fetching on new time bounds', async () => {
renderTimeUnitTab({
mode: modeOptionToMode.new,
data: { bid: undefined } as unknown as TimeBoundDetailsType,
})
const guidance = await screen.findByTestId('time-units-disabled')
expect(guidance).toBeTruthy()
expect(mockGetTimeBoundTimeUnitsQuery).toHaveBeenCalledWith(skipToken)
})
})