Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as fs from 'fs'
import * as path from 'path'

/**
* All dashboard HTTP calls must go through `fetchApi` so status handling
* (401 / 403 / 404 / 419 / 5xx / network) stays consistent in the UI.
* Most modules use `_get` / `_post` from `apiMethod.ts`, which delegates to
* `fetchApi`.
*/
const API_METHODS_ROOT = path.join(__dirname, '..')

const IGNORED_FILES = new Set(['fetchApi.ts', 'apiMethod.ts'])

describe('API HTTP layer consistency', () => {
it('each apiMethods/*.ts module imports fetchApi or apiMethod (not ad-hoc axios)', () => {
const entries = fs.readdirSync(API_METHODS_ROOT, { withFileTypes: true })
const offenders: string[] = []

for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.ts')) {
continue
}
if (IGNORED_FILES.has(entry.name)) {
continue
}

const fullPath = path.join(API_METHODS_ROOT, entry.name)
const source = fs.readFileSync(fullPath, 'utf8')

const usesFetchApi = /from\s+['"]\.\/fetchApi['"]/.test(source)
const usesApiMethod = /from\s+['"]\.\/apiMethod['"]/.test(source)

if (!usesFetchApi && !usesApiMethod) {
offenders.push(entry.name)
continue
}

// Guard against bypassing the shared layer with a direct axios import
if (/from\s+['"]axios['"]/.test(source)) {
offenders.push(`${entry.name} (imports axios directly)`)
}
}

expect(offenders).toEqual([])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Contract between `fetchApi.ts` and UI-facing behaviour.
* Detailed assertions live in `fetchApi.test.ts`; this suite keeps the
* matrix visible and stable for reviewers.
*/

describe('fetchApi HTTP status → UI handling contract', () => {
const contract: Record<
string,
{ ui: string; notes?: string }
> = {
'0': {
ui: 'toast.error (network / throttled)',
notes: 'Also used when response missing or status 0 (non-abort)',
},
'401': {
ui: 'window.location.replace("login.jsp")',
},
'403': {
ui: 'toast.error deferred (setTimeout 0), toastId fetch-api-http-403',
notes: 'No redirect; message from body or "You are not authorized"',
},
'404': {
ui: 'serverErrorHandler(..., "Resource not found")',
},
'419': {
ui: 'toast.warning "Session Time Out !!" + login.jsp redirect',
},
'500': {
ui: 'serverErrorHandler(..., "Internal Server Error")',
},
'503': {
ui: 'serverErrorHandler(..., "Service Unavailable")',
},
'504': {
ui: 'serverErrorHandler(..., "Gateway Timeout")',
},
default: {
ui: 'no automatic toast in switch; error rethrown',
notes: 'Callers may use getApiErrorToastMessage in catch',
},
}

it('defines the expected status handling map', () => {
expect(Object.keys(contract).sort()).toEqual([
'0',
'401',
'403',
'404',
'419',
'500',
'503',
'504',
'default',
])
})

it('each mapped status documents non-empty UI behaviour', () => {
for (const key of Object.keys(contract)) {
expect(contract[key].ui.length).toBeGreaterThan(0)
}
})
})
166 changes: 166 additions & 0 deletions dashboard/src/api/apiMethods/__tests__/apiMethod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Unit tests for apiMethod.ts
*
* Coverage Target:
* - Statements: ≥80% (target: 4/4 = 100%)
* - Functions: ≥80% (target: 4/4 = 100%)
* - Lines: ≥80% (target: 4/4 = 100%)
*/

import { _get, _post, _put, _delete } from '../apiMethod'
import { fetchApi } from '../fetchApi'

// Mock fetchApi
jest.mock('../fetchApi', () => ({
fetchApi: jest.fn()
}))

describe('apiMethod', () => {
const mockFetchApi = fetchApi as jest.MockedFunction<typeof fetchApi>
const mockUrl = '/api/test'
const mockConfig = {
method: 'GET',
params: { id: '123' },
data: { name: 'test' }
}
const mockResponse = {
data: { success: true },
status: 200,
statusText: 'OK',
headers: {},
config: {}
} as any

beforeEach(() => {
jest.clearAllMocks()
mockFetchApi.mockResolvedValue(mockResponse)
})

describe('_get', () => {
it('should call fetchApi with GET method', async () => {
const result = await _get(mockUrl, mockConfig)

expect(mockFetchApi).toHaveBeenCalledWith(mockUrl, mockConfig)
expect(result).toEqual(mockResponse)
})

it('should pass through all config parameters', async () => {
const customConfig = {
method: 'GET',
params: { filter: 'active' },
data: { query: 'test' }
}

await _get(mockUrl, customConfig)

expect(mockFetchApi).toHaveBeenCalledWith(mockUrl, customConfig)
})
})

describe('_post', () => {
it('should call fetchApi with POST method', async () => {
const postConfig = {
...mockConfig,
method: 'POST'
}

const result = await _post(mockUrl, postConfig)

expect(mockFetchApi).toHaveBeenCalledWith(mockUrl, postConfig)
expect(result).toEqual(mockResponse)
})

it('should pass through data in config', async () => {
const postConfig = {
method: 'POST',
params: {},
data: { name: 'new item' }
}

await _post(mockUrl, postConfig)

expect(mockFetchApi).toHaveBeenCalledWith(mockUrl, postConfig)
})
})

describe('_put', () => {
it('should call fetchApi with PUT method', async () => {
const putConfig = {
...mockConfig,
method: 'PUT'
}

const result = await _put(mockUrl, putConfig)

expect(mockFetchApi).toHaveBeenCalledWith(mockUrl, putConfig)
expect(result).toEqual(mockResponse)
})

it('should pass through update data', async () => {
const putConfig = {
method: 'PUT',
params: { id: '123' },
data: { name: 'updated item' }
}

await _put(mockUrl, putConfig)

expect(mockFetchApi).toHaveBeenCalledWith(mockUrl, putConfig)
})
})

describe('_delete', () => {
it('should call fetchApi with DELETE method', async () => {
const deleteConfig = {
method: 'DELETE',
params: { id: '123' }
}

const result = await _delete(mockUrl, deleteConfig)

expect(mockFetchApi).toHaveBeenCalledWith(mockUrl, deleteConfig)
expect(result).toEqual(mockResponse)
})

it('should handle delete without data', async () => {
const deleteConfig = {
method: 'DELETE',
params: { id: '123' }
}

await _delete(mockUrl, deleteConfig)

expect(mockFetchApi).toHaveBeenCalledWith(mockUrl, deleteConfig)
})
})

describe('Error Handling', () => {
it('should propagate errors from fetchApi', async () => {
const error = new Error('API Error')
mockFetchApi.mockRejectedValue(error)

await expect(_get(mockUrl, mockConfig)).rejects.toThrow('API Error')
})

it('should propagate errors for POST', async () => {
const error = new Error('POST Error')
mockFetchApi.mockRejectedValue(error)

await expect(_post(mockUrl, mockConfig)).rejects.toThrow('POST Error')
})

it('should propagate errors for PUT', async () => {
const error = new Error('PUT Error')
mockFetchApi.mockRejectedValue(error)

await expect(_put(mockUrl, mockConfig)).rejects.toThrow('PUT Error')
})

it('should propagate errors for DELETE', async () => {
const error = new Error('DELETE Error')
mockFetchApi.mockRejectedValue(error)

await expect(_delete(mockUrl, mockConfig)).rejects.toThrow('DELETE Error')
})
})
})
Loading
Loading