Files
gramps-web/test/unit/api.test.js
2026-07-29 14:58:56 +00:00

377 lines
10 KiB
JavaScript

import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'
import {
apiGet,
apiGetTokens,
apiRegisterUser,
apiResetPassword,
apiGetOIDCConfig,
createFirstTree,
updateTaskStatus,
} from '../../src/api.js'
describe('apiGet authentication', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({}),
headers: {get: () => null},
})
)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('includes Authorization header when auth is provided', async () => {
const mockAuth = {
getValidAccessToken: vi.fn().mockResolvedValue('test-token'),
}
await apiGet(mockAuth, '/api/tasks/test-123')
expect(fetch).toHaveBeenCalled()
const [, options] = fetch.mock.calls[0]
expect(options.headers).to.have.property(
'Authorization',
'Bearer test-token'
)
})
it('returns an error when auth is undefined and backend returns 401', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 401,
ok: false,
statusText: 'Unauthorized',
json: () => Promise.resolve({error: {message: 'Unauthorized'}}),
headers: {get: () => null},
})
)
const result = await apiGet(undefined, '/api/tasks/test-123')
expect(result.error).toBeDefined()
expect(result.error).to.be.a('string')
})
})
describe('apiGetTokens error handling', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns error.message from JSON body on non-200 response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 422,
statusText: 'UNPROCESSABLE ENTITY',
json: () =>
Promise.resolve({
error: {
message: 'username: Missing data for required field.',
},
}),
})
)
const result = await apiGetTokens('', '')
expect(result.error).toBe('username: Missing data for required field.')
})
it('falls back to statusText when JSON body has no error.message', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 503,
statusText: 'Service Unavailable',
json: () => Promise.resolve({}),
})
)
const result = await apiGetTokens('user', 'pass')
expect(result.error).toBe('Service Unavailable')
})
it('falls back to Error <status> when JSON parse fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 503,
statusText: '',
json: () => Promise.reject(new SyntaxError('Unexpected token')),
})
)
const result = await apiGetTokens('user', 'pass')
expect(result.error).toBe('Error 503')
})
it('surfaces JSON parse failure on 200 response as an error', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 200,
statusText: 'OK',
json: () => Promise.reject(new SyntaxError('Unexpected token')),
})
)
const result = await apiGetTokens('user', 'pass')
expect(result.error).toBeDefined()
expect(result.error).not.toBe('Access token missing in response')
})
})
describe('createFirstTree', () => {
function makeAppState({postResult, putResult} = {}) {
return {
apiPost: vi
.fn()
.mockResolvedValue(postResult ?? {data: {id: 'tree-123'}}),
apiPut: vi.fn().mockResolvedValue(putResult ?? {data: {}}),
refreshTokenIfNeeded: vi.fn().mockResolvedValue(undefined),
}
}
it('creates the tree, assigns it, and refreshes the token', async () => {
const appState = makeAppState()
const result = await createFirstTree(appState, 'My Family Tree')
expect(appState.apiPost).toHaveBeenCalledWith(
'/api/trees/',
{name: 'My Family Tree'},
{dbChanged: false}
)
expect(appState.apiPut).toHaveBeenCalledWith(
'/api/users/-/',
{tree: 'tree-123'},
{dbChanged: false}
)
expect(appState.refreshTokenIfNeeded).toHaveBeenCalledWith(true)
expect(result).toEqual({})
})
it('propagates an error from creating the tree without assigning it', async () => {
const appState = makeAppState({postResult: {error: 'Tree is required'}})
const result = await createFirstTree(appState, 'My Family Tree')
expect(result).toEqual({error: 'Tree is required'})
expect(appState.apiPut).not.toHaveBeenCalled()
expect(appState.refreshTokenIfNeeded).not.toHaveBeenCalled()
})
it('returns an error without assigning when the created tree has no id', async () => {
const appState = makeAppState({postResult: {data: {}}})
const result = await createFirstTree(appState, 'My Family Tree')
expect(result.error).to.be.a('string')
expect(appState.apiPut).not.toHaveBeenCalled()
expect(appState.refreshTokenIfNeeded).not.toHaveBeenCalled()
})
it('propagates an error from assigning the tree without refreshing the token', async () => {
const appState = makeAppState({putResult: {error: 'Not authorized'}})
const result = await createFirstTree(appState, 'My Family Tree')
expect(result).toEqual({error: 'Not authorized'})
expect(appState.refreshTokenIfNeeded).not.toHaveBeenCalled()
})
})
describe('apiResetPassword error handling', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns error.message from JSON body on unexpected status', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 422,
statusText: 'UNPROCESSABLE ENTITY',
json: () =>
Promise.resolve({error: {message: 'username: Missing field.'}}),
})
)
const result = await apiResetPassword('user')
expect(result.error).toBe('username: Missing field.')
})
it('falls back to statusText when response body is not JSON', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 400,
statusText: 'Bad Request',
json: () => Promise.reject(new SyntaxError('Unexpected token')),
})
)
const result = await apiResetPassword('user')
expect(result.error).toBe('Bad Request')
})
})
describe('apiRegisterUser error handling', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns error.message from JSON body on non-201 response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 422,
statusText: 'UNPROCESSABLE ENTITY',
json: () =>
Promise.resolve({error: {message: 'password: Missing field.'}}),
})
)
const result = await apiRegisterUser('user', 'pass', '', '', '')
expect(result.error).toBe('password: Missing field.')
})
it('falls back to statusText when JSON body has no error.message', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({}),
})
)
const result = await apiRegisterUser('user', 'pass', '', '', '')
expect(result.error).toBe('Internal Server Error')
})
})
describe('apiGetOIDCConfig error handling', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns error.message from JSON body on error response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () =>
Promise.resolve({error: {message: 'OIDC provider misconfigured.'}}),
})
)
const result = await apiGetOIDCConfig()
expect(result.error).toBe('OIDC provider misconfigured.')
})
it('falls back to statusText on error response with non-JSON body', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 502,
statusText: 'Bad Gateway',
json: () => Promise.reject(new SyntaxError('Unexpected token')),
})
)
const result = await apiGetOIDCConfig()
expect(result.error).toBe('Bad Gateway')
})
it('surfaces JSON parse failure on ok response as an error', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('Unexpected token')),
})
)
const result = await apiGetOIDCConfig()
expect(result.error).toBeDefined()
})
})
describe('updateTaskStatus cleanup behavior', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('stops polling when shouldContinue becomes false', async () => {
vi.useFakeTimers()
let keepPolling = true
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 200,
ok: true,
json: () =>
Promise.resolve({state: 'PENDING', result_object: {progress: 0.25}}),
headers: {get: () => null},
})
)
const callback = vi.fn(() => {
keepPolling = false
})
const promise = updateTaskStatus(
{getValidAccessToken: vi.fn().mockResolvedValue('test-token')},
'task-1',
callback,
1000,
Infinity,
() => keepPolling
)
await Promise.resolve()
await vi.runAllTimersAsync()
await promise
expect(fetch).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledTimes(1)
})
it('does not schedule another wait after a terminal task state', async () => {
vi.useFakeTimers()
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({state: 'SUCCESS'}),
headers: {get: () => null},
})
)
await updateTaskStatus(
{getValidAccessToken: vi.fn().mockResolvedValue('test-token')},
'task-2',
vi.fn(),
1000
)
expect(fetch).toHaveBeenCalledTimes(1)
expect(timeoutSpy).not.toHaveBeenCalled()
})
})