diff --git a/frontend/src/features/application/service.test.ts b/frontend/src/features/application/service.test.ts new file mode 100644 index 00000000..3cbdfb50 --- /dev/null +++ b/frontend/src/features/application/service.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { http } from '@/http' +import { + fetchApplications, + fetchApplicationDetail, + submitReview, + claimApplication, + releaseApplication, + submitFeedback, + getDownloadUrl, +} from './service' + +vi.mock('@/http', () => ({ + API_BASE_URL: 'http://localhost:8080', + http: { + request: vi.fn(), + }, +})) + +describe('application service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchApplications sends list query params', async () => { + const mockResponse = { data: { items: [], total: 0, page: 1, pageSize: 20 } } + vi.mocked(http.request).mockResolvedValue(mockResponse) + + const result = await fetchApplications({ consignmentId: 'C123', page: 1, pageSize: 20 }) + + expect(http.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8080/api/v1/applications', + method: 'GET', + params: { consignmentId: 'C123', page: 1, pageSize: 20 }, + attachToken: true, + }), + ) + expect(result).toEqual(mockResponse.data) + }) + + it('fetchApplicationDetail requests the task by id', async () => { + const mockApp = { taskId: 'T-100', title: 'Inspection Application' } + vi.mocked(http.request).mockResolvedValue({ data: mockApp }) + + const result = await fetchApplicationDetail('T-100') + + expect(http.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8080/api/v1/applications/T-100', + method: 'GET', + attachToken: true, + }), + ) + expect(result).toEqual(mockApp) + }) + + it('submitReview posts form values to the review endpoint', async () => { + const mockResult = { status: 'APPROVED' } + vi.mocked(http.request).mockResolvedValue({ data: mockResult }) + + const formValues = { decision: 'APPROVED', remarks: 'Looks good' } + const result = await submitReview('T-100', formValues) + + expect(http.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8080/api/v1/applications/T-100/review', + method: 'POST', + data: formValues, + attachToken: true, + }), + ) + expect(result).toEqual(mockResult) + }) + + it('claimApplication posts to the claim endpoint', async () => { + vi.mocked(http.request).mockResolvedValue({ data: undefined }) + + await claimApplication('T-100') + + expect(http.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8080/api/v1/applications/T-100/claim', + method: 'POST', + attachToken: true, + }), + ) + }) + + it('releaseApplication posts to the release endpoint', async () => { + vi.mocked(http.request).mockResolvedValue({ data: undefined }) + + await releaseApplication('T-100') + + expect(http.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8080/api/v1/applications/T-100/release', + method: 'POST', + attachToken: true, + }), + ) + }) + + it('submitFeedback posts content to the feedback endpoint', async () => { + const mockResult = { status: 'FEEDBACK_REQUESTED' } + vi.mocked(http.request).mockResolvedValue({ data: mockResult }) + + const content = { comment: 'Please clarify declaration' } + const result = await submitFeedback('T-100', content) + + expect(http.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8080/api/v1/applications/T-100/feedback', + method: 'POST', + data: content, + attachToken: true, + }), + ) + expect(result).toEqual(mockResult) + }) + + it('getDownloadUrl maps storage metadata to url and expiry', async () => { + vi.mocked(http.request).mockResolvedValue({ + data: { download_url: 'http://localhost:8080/downloads/file.pdf', expires_at: 1700000000 }, + }) + + const result = await getDownloadUrl('file-key-123') + + expect(http.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8080/api/v1/storage/file-key-123', + method: 'GET', + attachToken: true, + }), + ) + expect(result).toEqual({ url: 'http://localhost:8080/downloads/file.pdf', expiresAt: 1700000000 }) + }) + + it('getDownloadUrl resolves a relative download path against the API base', async () => { + vi.mocked(http.request).mockResolvedValue({ + data: { download_url: '/downloads/file.pdf', expires_at: 1700000000 }, + }) + + const result = await getDownloadUrl('file-key-123') + + expect(result).toEqual({ url: 'http://localhost:8080/downloads/file.pdf', expiresAt: 1700000000 }) + }) +}) diff --git a/frontend/src/features/consignment/hooks/useConsignmentList.test.ts b/frontend/src/features/consignment/hooks/useConsignmentList.test.ts new file mode 100644 index 00000000..75b9fcc5 --- /dev/null +++ b/frontend/src/features/consignment/hooks/useConsignmentList.test.ts @@ -0,0 +1,36 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useConsignmentList } from './useConsignmentList' +import * as consignmentService from '../service' + +vi.mock('../service', () => ({ + fetchConsignments: vi.fn(), +})) + +describe('useConsignmentList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads consignments on mount', async () => { + const mockItems = [{ id: 'C1', consignmentNumber: 'CN-100', status: 'SUBMITTED' }] + vi.mocked(consignmentService.fetchConsignments).mockResolvedValue({ + items: mockItems as never, + total: 1, + page: 1, + pageSize: 20, + }) + + const { result } = renderHook(() => useConsignmentList('')) + + expect(result.current.status.loading).toBe(true) + + await waitFor(() => { + expect(result.current.status.loading).toBe(false) + }) + + expect(result.current.data).toEqual(mockItems) + expect(result.current.pagination.total).toBe(1) + expect(result.current.pagination.totalPages).toBe(1) + }) +}) diff --git a/frontend/src/features/consignment/service.test.ts b/frontend/src/features/consignment/service.test.ts new file mode 100644 index 00000000..2bbaf9ca --- /dev/null +++ b/frontend/src/features/consignment/service.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { http } from '@/http' +import { fetchConsignments } from './service' + +vi.mock('@/http', () => ({ + API_BASE_URL: 'http://localhost:8080', + http: { + request: vi.fn(), + }, +})) + +describe('consignment service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchConsignments sends search and pagination params', async () => { + const mockResponse = { data: { items: [], total: 0, page: 1, pageSize: 20 } } + vi.mocked(http.request).mockResolvedValue(mockResponse) + + const result = await fetchConsignments({ q: 'tea', page: 1, pageSize: 20 }) + + expect(http.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8080/api/v1/consignments', + method: 'GET', + params: { q: 'tea', page: 1, pageSize: 20 }, + attachToken: true, + }), + ) + expect(result).toEqual(mockResponse.data) + }) +}) diff --git a/frontend/src/hooks/useDebounce.test.ts b/frontend/src/hooks/useDebounce.test.ts new file mode 100644 index 00000000..a6e3d289 --- /dev/null +++ b/frontend/src/hooks/useDebounce.test.ts @@ -0,0 +1,30 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { useDebounce } from './useDebounce' + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps the previous value until the delay elapses', () => { + const { result, rerender } = renderHook(({ val }) => useDebounce(val, 400), { + initialProps: { val: 'hello' }, + }) + + expect(result.current).toBe('hello') + + rerender({ val: 'world' }) + expect(result.current).toBe('hello') + + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(result.current).toBe('world') + }) +}) diff --git a/frontend/src/http.test.ts b/frontend/src/http.test.ts new file mode 100644 index 00000000..77015f36 --- /dev/null +++ b/frontend/src/http.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { http } from './http' +import { userManager } from '@/features/user/oidcUserManager' + +vi.mock('./runtimeConfig', () => ({ + getRequiredEnv: () => 'http://localhost:8080', +})) + +vi.mock('@/features/user/oidcUserManager', () => ({ + userManager: { + getUser: vi.fn(), + }, +})) + +function jsonOk(body: unknown) { + return { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: (): Promise => Promise.resolve(body), + } +} + +describe('http client', () => { + const mockedFetch = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockedFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('attaches authorization header when attachToken is true', async () => { + vi.mocked(userManager).getUser.mockResolvedValue({ + access_token: 'test-bearer-token', + } as never) + mockedFetch.mockResolvedValue(jsonOk({ status: 'ok' })) + + const res = await http.request({ + url: 'http://localhost:8080/api/v1/test', + attachToken: true, + }) + + expect(mockedFetch).toHaveBeenCalledTimes(1) + expect(mockedFetch.mock.calls[0]?.[0]).toBe('http://localhost:8080/api/v1/test') + expect(mockedFetch.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: 'Bearer test-bearer-token' }, + }) + expect(res).toEqual({ data: { status: 'ok' } }) + }) + + it('omits null and undefined query parameters', async () => { + mockedFetch.mockResolvedValue(jsonOk({ items: [] })) + + await http.request({ + url: 'http://localhost:8080/api/v1/search', + params: { q: 'tea', page: 1, empty: undefined, nullVal: null }, + }) + + expect(mockedFetch.mock.calls[0]?.[0]).toBe('http://localhost:8080/api/v1/search?q=tea&page=1') + }) + + it('throws when the response is not ok', async () => { + mockedFetch.mockResolvedValue({ ok: false, status: 404 }) + + await expect( + http.request({ + url: 'http://localhost:8080/api/v1/notfound', + }), + ).rejects.toThrow('HTTP error! status: 404') + }) +}) diff --git a/frontend/src/http.ts b/frontend/src/http.ts index 15c1fb15..2e5b2438 100644 --- a/frontend/src/http.ts +++ b/frontend/src/http.ts @@ -14,7 +14,7 @@ interface RequestConfig { } export const http = { - request: async (config: RequestConfig) => { + request: async (config: RequestConfig): Promise<{ data: unknown }> => { let url = config.url if (config.params) { const searchParams = new URLSearchParams() diff --git a/frontend/src/utils/date.test.ts b/frontend/src/utils/date.test.ts new file mode 100644 index 00000000..bab54683 --- /dev/null +++ b/frontend/src/utils/date.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { formatDateForTable } from './date' + +vi.mock('@/i18n', () => ({ + default: { + resolvedLanguage: 'en-US', + }, +})) + +describe('formatDateForTable', () => { + beforeEach(() => { + vi.stubEnv('TZ', 'UTC') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('returns "-" when date string is missing', () => { + expect(formatDateForTable()).toBe('-') + expect(formatDateForTable('')).toBe('-') + }) + + it('formats a valid ISO date for table display', () => { + expect(formatDateForTable('2026-08-10T10:00:00Z')).toBe('Aug 10, 2026') + }) +})