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