f761cbf854
* tests(integration): mock server validates requests * chore(logging): improve error message * more logging improvements * fix logging which failed if response was not json * Use createError method in mock server * Improve mock server error presentation * Add speckle_function_command to integration test using github action * fix broken merge; ours strategy was too agressive * Resolve 'a sequence was not expected' error message in main.yml * Tidy up commit to match existing in main * use tag if available, otherwise commit sha.
276 lines
8.9 KiB
TypeScript
276 lines
8.9 KiB
TypeScript
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
|
|
import { setupServer } from 'msw/node'
|
|
import { rest } from 'msw'
|
|
import { getMinimalSpeckleFunctionExample } from '../schema/specklefunction.spec.js'
|
|
import httpClient, {
|
|
defaultClientErrorHandler,
|
|
throwErrorOnClientErrorStatusCode,
|
|
NonRetryableError,
|
|
RetryableError
|
|
} from './client.js'
|
|
import { getLogger } from '../tests/logger.js'
|
|
import { ValidationError } from 'zod-validation-error'
|
|
import { AttemptContext } from '@lifeomic/attempt'
|
|
import fetch, { AbortError, FetchError } from 'node-fetch'
|
|
import { Readable } from 'stream'
|
|
|
|
describe('client', () => {
|
|
const server = setupServer()
|
|
|
|
beforeAll(() => {
|
|
server.listen({ onUnhandledRequest: 'error' })
|
|
})
|
|
afterAll(() => {
|
|
server.close()
|
|
})
|
|
afterEach(() => {
|
|
server.resetHandlers()
|
|
})
|
|
|
|
function createFakeContext(): AttemptContext {
|
|
return {
|
|
attemptNum: 0,
|
|
attemptsRemaining: 0,
|
|
aborted: false,
|
|
abort() {
|
|
this.aborted = true
|
|
}
|
|
}
|
|
}
|
|
|
|
function fakeErrorHandler(error: unknown, context: AttemptContext) {
|
|
const fakeContext = createFakeContext()
|
|
defaultClientErrorHandler(error, createFakeContext())
|
|
context.aborted = fakeContext.aborted
|
|
}
|
|
|
|
describe('postManifest', () => {
|
|
describe('valid input', () => {
|
|
it('should respond to client with image name, function id, and version id', async () => {
|
|
server.use(
|
|
rest.post(
|
|
'https://success.automate.speckle.example.org/api/v1/functions/functionid/versions',
|
|
async (req, res, ctx) => {
|
|
expect(await req.json()).toStrictEqual({
|
|
versionTag: 'main',
|
|
commitId: '1234567890',
|
|
command: [],
|
|
inputSchema: {},
|
|
annotations: getMinimalSpeckleFunctionExample().metadata?.annotations
|
|
})
|
|
expect(req.headers.get('Authorization')).toBe('Bearer supersecret')
|
|
const response = await res(
|
|
ctx.status(201),
|
|
ctx.json({
|
|
versionId: 'minimalversionid'
|
|
})
|
|
)
|
|
return response
|
|
}
|
|
)
|
|
)
|
|
|
|
const test = httpClient.postManifest(
|
|
'https://success.automate.speckle.example.org',
|
|
'supersecret',
|
|
'functionid',
|
|
{
|
|
versionTag: 'main',
|
|
commitId: '1234567890',
|
|
inputSchema: {},
|
|
command: [],
|
|
annotations: getMinimalSpeckleFunctionExample()?.metadata?.annotations
|
|
},
|
|
getLogger(),
|
|
fetch,
|
|
fakeErrorHandler
|
|
)
|
|
|
|
await expect(test).resolves.toStrictEqual({
|
|
versionId: 'minimalversionid'
|
|
})
|
|
})
|
|
})
|
|
describe('server responds with a 500 HTTP Status Code', () => {
|
|
it('should throw an error', async () => {
|
|
server.use(
|
|
rest.post(
|
|
'https://failure.automate.speckle.example.org/api/v1/functions/functionid/versions',
|
|
async (req, res, ctx) => {
|
|
const response = await res(ctx.status(500))
|
|
return response
|
|
}
|
|
)
|
|
)
|
|
await expect(
|
|
httpClient.postManifest(
|
|
'https://failure.automate.speckle.example.org',
|
|
'sometoken',
|
|
'functionid',
|
|
{
|
|
versionTag: '',
|
|
commitId: '',
|
|
command: [],
|
|
inputSchema: {},
|
|
annotations: getMinimalSpeckleFunctionExample()?.metadata?.annotations
|
|
},
|
|
getLogger(),
|
|
fetch,
|
|
fakeErrorHandler
|
|
)
|
|
).rejects.toThrow()
|
|
})
|
|
})
|
|
describe('server responds with an unexpected response body', () => {
|
|
it('should throw an error', async () => {
|
|
server.use(
|
|
rest.post(
|
|
'https://unexpectedresponse.automate.speckle.example.org/api/v1/functions/functionid/versions',
|
|
async (req, res, ctx) => {
|
|
const response = await res(
|
|
ctx.status(201),
|
|
ctx.json({
|
|
unexpected: 'unexpected'
|
|
})
|
|
)
|
|
return response
|
|
}
|
|
)
|
|
)
|
|
|
|
await expect(
|
|
httpClient.postManifest(
|
|
'https://unexpectedresponse.automate.speckle.example.org',
|
|
'sometoken',
|
|
'functionid',
|
|
{
|
|
versionTag: '',
|
|
commitId: '',
|
|
command: [],
|
|
inputSchema: {},
|
|
annotations: getMinimalSpeckleFunctionExample()?.metadata?.annotations
|
|
},
|
|
getLogger(),
|
|
fetch,
|
|
fakeErrorHandler
|
|
)
|
|
).rejects.toThrow(ValidationError)
|
|
})
|
|
})
|
|
describe('invalid input', () => {
|
|
describe('empty url', () => {
|
|
it('should throw an error', async () => {
|
|
await expect(
|
|
httpClient.postManifest(
|
|
'',
|
|
'supersecret',
|
|
'functionid',
|
|
{
|
|
versionTag: 'main',
|
|
commitId: '1234567890',
|
|
command: [],
|
|
inputSchema: {},
|
|
annotations: getMinimalSpeckleFunctionExample()?.metadata?.annotations
|
|
},
|
|
getLogger(),
|
|
fetch,
|
|
fakeErrorHandler
|
|
)
|
|
).rejects.toThrow(Error)
|
|
})
|
|
})
|
|
describe('empty token', () => {
|
|
it('should throw an error', async () => {
|
|
await expect(
|
|
httpClient.postManifest(
|
|
'https://example.org',
|
|
'',
|
|
'functionid',
|
|
{
|
|
versionTag: 'main',
|
|
commitId: '1234567890',
|
|
inputSchema: {},
|
|
command: [],
|
|
annotations: getMinimalSpeckleFunctionExample()?.metadata?.annotations
|
|
},
|
|
getLogger(),
|
|
fetch,
|
|
fakeErrorHandler
|
|
)
|
|
).rejects.toThrow(Error)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
describe('defaultClientErrorHandler', () => {
|
|
it('aborts unknown errors', () => {
|
|
const context = createFakeContext()
|
|
expect(context.aborted).toBeFalsy()
|
|
defaultClientErrorHandler('🚨', context)
|
|
expect(context.aborted).toBeTruthy()
|
|
})
|
|
it('aborts when the error is a fetch abort error', () => {
|
|
const context = createFakeContext()
|
|
expect(context.aborted).toBeFalsy()
|
|
defaultClientErrorHandler(new AbortError('aborting'), context)
|
|
expect(context.aborted).toBeTruthy()
|
|
})
|
|
it('aborts when the error is a fetch error', () => {
|
|
const context = createFakeContext()
|
|
expect(context.aborted).toBeFalsy()
|
|
defaultClientErrorHandler(new FetchError('not found', 'ENOTFOUND'), context)
|
|
expect(context.aborted).toBeTruthy()
|
|
})
|
|
it('does not abort when the error is a retryable error', () => {
|
|
const context = createFakeContext()
|
|
expect(context.aborted).toBeFalsy()
|
|
defaultClientErrorHandler(new RetryableError('retryable'), context)
|
|
expect(context.aborted).toBeFalsy()
|
|
})
|
|
})
|
|
describe('throwErrorOnClientErrorStatusCode', () => {
|
|
it('does not throw an error on 2xx', async () => {
|
|
const result = await throwErrorOnClientErrorStatusCode(async () => {
|
|
return { body: Readable.from(''), status: 200 }
|
|
})()
|
|
expect(result.status).toBe(200)
|
|
})
|
|
it('does not throw an error on 3xx', async () => {
|
|
const result = await throwErrorOnClientErrorStatusCode(async () => {
|
|
return { body: Readable.from(''), status: 300 }
|
|
})()
|
|
expect(result.status).toBe(300)
|
|
})
|
|
it('throws an error on 4xx', async () => {
|
|
const result = throwErrorOnClientErrorStatusCode(async () => {
|
|
return { body: Readable.from(''), status: 400 }
|
|
})()
|
|
await expect(result).rejects.toThrow(NonRetryableError)
|
|
})
|
|
it('throws an error on 5xx', async () => {
|
|
const result = throwErrorOnClientErrorStatusCode(async () => {
|
|
return { body: Readable.from(''), status: 500 }
|
|
})()
|
|
await expect(result).rejects.toThrow(RetryableError)
|
|
})
|
|
it('fetch errors are passed through', async () => {
|
|
const result = throwErrorOnClientErrorStatusCode(async () => {
|
|
throw new FetchError('not found', 'ENOTFOUND')
|
|
})()
|
|
await expect(result).rejects.toThrow(FetchError)
|
|
})
|
|
it('abort errors are passed through', async () => {
|
|
const result = throwErrorOnClientErrorStatusCode(async () => {
|
|
throw new AbortError('aborting error')
|
|
})()
|
|
await expect(result).rejects.toThrow(AbortError)
|
|
})
|
|
it('unknown errors are passed through', async () => {
|
|
const result = throwErrorOnClientErrorStatusCode(async () => {
|
|
throw new Error('unknown error')
|
|
})()
|
|
await expect(result).rejects.toThrow(Error)
|
|
})
|
|
})
|
|
})
|