Files
speckle-automate-github-action/src/client/client.spec.ts
T
Iain Sproat f761cbf854 tests(integration): mock server validates requests (#130)
* 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.
2023-06-23 15:58:26 +02:00

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)
})
})
})