import { App, Store } from 'tapimo' import { data, funding, management, mpp, relay } from 'tapimo/apps' import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' import * as TestApp from '../../test/App.js' import * as Relay from '../../test/Relay.js' // Each test mounts one group and asserts its routes exist while omitted groups // are absent (404 and not in the document). The management case also locks its // OpenAPI group name; tags and schemes otherwise stay a superset (Decision C). /** OpenAPI document from a composed app. */ async function document(app: { request: (path: string) => Promise | Response }) { const res = await app.request('/openapi.json') return (await res.json()) as { components: { securitySchemes: Record } paths: Record< string, Partial> > tags: readonly { description: string name: string 'x-displayName'?: string | undefined 'x-pagePath'?: string | undefined }[] webhooks?: Record | undefined 'x-tagGroups'?: readonly { name: string; tags: readonly string[] }[] | undefined } } /** Paths (sans method) present in a composed app's OpenAPI document. */ async function paths(app: { request: (path: string) => Promise | Response }) { return Object.keys((await document(app)).paths) } describe('App.create()', () => { test('publishes shared chain context without data routes', async () => { const rpc = { url: () => 'https://rpc.example' } const group = new Hono().get('/context', (c) => { const client = c.get('getClient')() return c.json({ chainId: c.get('chainId'), clientChainId: client.chain.id, memoized: client === c.get('getClient')(), rpc: c.get('rpc') === rpc, supportedChainIds: [...c.get('supportedChainIds')].sort((a, b) => a - b), }) }) const app = App.create({ auth: false, db: TestApp.database(), defaultChainId: 1337, rpc, supportedChainIds: [999], }).route('/', group) const response = await app.request('/context') expect(await response.json()).toMatchInlineSnapshot(` { "chainId": 1337, "clientChainId": 1337, "memoized": true, "rpc": true, "supportedChainIds": [ 999, 1337, 4217, 42431, ], } `) }) }) describe('App.from()', () => { test('contributes external route-group metadata', async () => { const group = new Hono().get( '/v1/external/webhooks', describeRoute({ responses: { 200: { description: 'External webhook status.' } }, summary: 'Get webhook status', tags: ['External'], }), (c) => c.json({ status: 'ok' }, 200), ) const external = App.from(group, { docs: ({ spec }) => new Hono().get('/external-docs', async (c) => c.json({ paths: (await spec()).paths })), securitySchemes: { externalKey: { in: 'header', name: 'external-key', type: 'apiKey' }, }, tags: [{ description: 'Externally defined routes.', name: 'External' }], webhooks: () => ({ externalEvent: { post: { responses: { 200: { description: 'OK' } } } } }), 'x-tagGroups': [{ name: 'External API', tags: ['External'] }], }) expectTypeOf(external).toEqualTypeOf() const app = App.create({ auth: false, db: TestApp.database() }).route('/', external) const spec = await document(app) expect({ securityScheme: spec.components.securitySchemes['externalKey'], tags: spec.tags, tagGroups: spec['x-tagGroups'], webhooks: spec.webhooks, }).toMatchInlineSnapshot(` { "securityScheme": { "in": "header", "name": "external-key", "type": "apiKey", }, "tagGroups": [ { "name": "External API", "tags": [ "External", ], }, ], "tags": [ { "description": "Externally defined routes.", "name": "External", }, ], "webhooks": { "externalEvent": { "post": { "responses": { "200": { "description": "OK", }, }, }, }, }, } `) const docs = await app.request('/external-docs') expect((await docs.json()).paths['/v1/external/webhooks']).toBeDefined() }) }) describe('data()', () => { test('mounts data routes and omits management routes', async () => { const app = App.create({ db: TestApp.database() }).route('/', data()) const keys = await paths(app) expect(keys.some((path) => path.startsWith('/v1/tokens'))).toBe(true) expect(keys.some((path) => path.startsWith('/v1/blocks'))).toBe(true) expect(keys.some((path) => path.startsWith('/v1/orgs'))).toBe(false) expect(keys.some((path) => path.startsWith('/v1/me'))).toBe(false) expect(keys.some((path) => path.startsWith('/v1/funding'))).toBe(false) // The omitted management route resolves to a 404 (never mounted), not a 401. const orgs = await app.request('/v1/orgs') expect(orgs.status).toBe(404) expect((await app.request('/v1/funding/chains')).status).toBe(404) }) test('inherits shared chain context', async () => { const rpc = { url: () => 'https://rpc.example' } const context = new Hono().get('/context', (c) => c.json({ chainId: c.get('chainId'), clientChainId: c.get('getClient')().chain.id, rpc: c.get('rpc') === rpc, supportedChainIds: [...c.get('supportedChainIds')].sort((a, b) => a - b), }), ) const app = App.create({ auth: false, db: TestApp.database(), defaultChainId: 333, rpc, supportedChainIds: [444], zones: true, }) .route('/', data()) .route('/', context) const response = await app.request('/context') expect(await response.json()).toMatchInlineSnapshot(` { "chainId": 333, "clientChainId": 333, "rpc": true, "supportedChainIds": [ 333, 444, 4217, 42431, 421700001, 421700006, 1424310003, ], } `) }) test('keeps RPC and TIDX chain capabilities separate', async () => { const both = 421_700_005 const rpcOnly = 31_318 const tidxOnly = 421_700_006 const server = await Relay.createServer((request, response) => { response.writeHead(200, { 'content-type': 'application/json' }) response.end( JSON.stringify( request.method === 'POST' ? { id: 1, jsonrpc: '2.0', result: '0x7a56' } : { columns: [], ok: true, row_count: 0, rows: [] }, ), ) }) try { const context = new Hono().get('/context', (c) => c.json({ supportedChainIds: [...c.get('supportedChainIds')].sort((a, b) => a - b) }), ) const app = App.create({ auth: false, db: TestApp.database(), rpc: { publicZoneUrl: JSON.stringify({ 421_700_007: 'https://zone-only.public-rpc.test', }), url: JSON.stringify({ [both]: server.url, [rpcOnly]: server.url }), }, }) .route( '/', data({ tidx: { baseUrl: JSON.stringify({ [both]: server.url, [tidxOnly]: server.url }), }, }), ) .route('/', context) expect(await (await app.request('/context')).json()).toMatchInlineSnapshot(` { "supportedChainIds": [ 4217, 31318, 42431, 421700005, 421700006, ], } `) for (const chainId of [rpcOnly, tidxOnly]) { const response = await app.request(`/v1/blocks/latest?chainId=${chainId}`) const body = (await response.json()) as { error: { code: string } } expect(response.status).toBe(400) expect(body.error.code).toBe('chain_id_unsupported') } const response_both = await app.request(`/v1/blocks/not-a-block?chainId=${both}`) const body_both = (await response_both.json()) as { error: { code: string } } expect(response_both.status).toBe(400) expect(body_both.error.code).toBe('block_invalid') const response_rpc = await app.request(`/rpc/${rpcOnly}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'content-type': 'application/json' }, method: 'POST', }) expect(response_rpc.status).toBe(200) const response_tidx = await app.request( `/v1/indexer/query?chainId=${tidxOnly}&sql=select%201`, ) expect(response_tidx.status).toBe(200) } finally { await server.closeAsync() } }) }) describe('funding()', () => { test('mounts the Funding & Bridge API independently', async () => { const group = funding() expectTypeOf(App.funding).toEqualTypeOf() expectTypeOf(group).toEqualTypeOf() const app = App.create({ auth: false, db: TestApp.database() }).route('/', group) const spec = await document(app) expect( Object.keys(spec.paths) .filter((path) => path.startsWith('/v1/funding')) .sort(), ).toEqual( [ '/v1/funding/chains', '/v1/funding/deposit-addresses', '/v1/funding/deposit-addresses/{id}', '/v1/funding/deposit-addresses/{id}/reconcile', '/v1/funding/deposits', '/v1/funding/deposits/{id}', '/v1/funding/providers', '/v1/funding/quotes', '/v1/funding/transfers', '/v1/funding/transfers/vault', '/v1/funding/transfers/zone', '/v1/funding/transfers/{id}', '/v1/funding/transfers/{id}/source-transactions', ].sort(), ) expect(Object.keys(spec.paths).some((path) => path.startsWith('/v1/tokens'))).toBe(false) expect(spec.paths['/webhooks/funding/{providerId}']).toBeUndefined() expect(spec['x-tagGroups']).toEqual([ { name: 'Funding & Bridge API', tags: ['Chains', 'Deposit Addresses', 'Providers', 'Quotes', 'Funding Transfers'], }, ]) expect( Object.values(spec.paths).flatMap((path) => [path.get, path.post].flatMap((operation) => operation?.tags?.includes('Deposit Addresses') ? [operation.operationId] : [], ), ), ).toEqual([ 'listFundingDepositAddresses', 'createFundingDepositAddress', 'reconcileFundingDepositAddress', 'getFundingDepositAddress', 'getFundingDeposit', 'listFundingDeposits', ]) expect((await app.request('/v1/tokens')).status).toBe(404) }) test('keeps data and funding transfers separate with the same display name', async () => { const app = App.create({ auth: false, db: TestApp.database() }) .route('/', data()) .route('/', funding()) const spec = await document(app) const operationTag = (path: string, method: 'get' | 'post') => (spec.paths[path] as Record)[ method ]?.tags expect({ chains: operationTag('/v1/funding/chains', 'get'), depositAddress: operationTag('/v1/funding/deposit-addresses', 'post'), deposits: operationTag('/v1/funding/deposits', 'get'), providers: operationTag('/v1/funding/providers', 'get'), quotes: operationTag('/v1/funding/quotes', 'get'), tokenTransfers: operationTag('/v1/transfers', 'get'), transfers: operationTag('/v1/funding/transfers', 'get'), vault: operationTag('/v1/funding/transfers/vault', 'post'), zone: operationTag('/v1/funding/transfers/zone', 'post'), }).toEqual({ chains: ['Chains'], depositAddress: ['Deposit Addresses'], deposits: ['Deposit Addresses'], providers: ['Providers'], quotes: ['Quotes'], tokenTransfers: ['Transfers'], transfers: ['Funding Transfers'], vault: ['Funding Transfers'], zone: ['Funding Transfers'], }) const groups = Object.fromEntries( (spec['x-tagGroups'] ?? []).map((group) => [group.name, group.tags]), ) expect(groups['Data API']).toContain('Transfers') expect(groups['Data API']).not.toContain('Funding Transfers') expect(groups['Funding & Bridge API']).toContain('Funding Transfers') expect(groups['Funding & Bridge API']).not.toContain('Transfers') expect(spec.tags.filter((tag) => tag.name === 'Transfers')).toHaveLength(1) expect(spec.tags.filter((tag) => tag.name === 'Funding Transfers')).toEqual([ { description: 'Inbound funding transfers.', name: 'Funding Transfers', 'x-displayName': 'Transfers', 'x-pagePath': 'funding/transfers', }, ]) expect( Object.fromEntries( (groups['Funding & Bridge API'] ?? []).map((name) => { const tag = spec.tags.find((tag) => tag.name === name) return [tag?.['x-displayName'] ?? tag?.name, tag?.['x-pagePath']] }), ), ).toEqual({ Chains: 'funding/chains', 'Deposit Addresses': 'funding/deposit-addresses', Providers: 'funding/providers', Quotes: 'funding/quotes', Transfers: 'funding/transfers', }) }) }) describe('relay()', () => { test('mounts the relay after data() without documenting RPC routes', async () => { const composed = App.create({ db: TestApp.database() }).route('/', data()).route('/', relay()) // Mounted but default-closed and hidden: unauthenticated requests are // refused, and the path never appears in the OpenAPI document. const relayMounted = await composed.request('/rpc/relay') const sponsorMounted = await composed.request('/rpc/sponsor') expect(relayMounted.status).toBe(401) expect(sponsorMounted.status).toBe(401) expect((await paths(composed)).some((path) => path.startsWith('/rpc/relay'))).toBe(false) expect((await paths(composed)).some((path) => path.startsWith('/rpc/sponsor'))).toBe(false) // Without the relay group, the data group no longer serves the path. const dataOnly = App.create({ db: TestApp.database() }).route('/', data()) expect((await dataOnly.request('/rpc/relay')).status).toBe(404) expect((await dataOnly.request('/rpc/sponsor')).status).toBe(404) }) }) describe('mpp()', () => { test('mounts documented MPP routes independently', async () => { const app = App.create({ db: TestApp.database() }).route('/', mpp({ state: Store.memory() })) expect((await paths(app)).some((path) => path.startsWith('/v1/mpp'))).toBe(true) expect((await app.request('/rpc/relay')).status).toBe(404) }) }) describe('management()', () => { test('mounts management routes and omits data routes', async () => { const app = App.create({ auth: { session: { wallet: { origin: 'https://test.example' } } }, db: TestApp.database(), kv: { store: Store.memory() }, }).route('/', management()) const spec = await document(app) const keys = Object.keys(spec.paths) expect(keys.some((path) => path.startsWith('/v1/orgs'))).toBe(true) expect(keys.some((path) => path.startsWith('/v1/me'))).toBe(true) expect(keys.some((path) => path.startsWith('/v1/tokens'))).toBe(false) expect(keys.some((path) => path.startsWith('/v1/blocks'))).toBe(false) expect(spec['x-tagGroups']?.map(({ name }) => name)).toMatchInlineSnapshot(` [ "Management API", ] `) // The omitted data route resolves to a 404 (never mounted). const token = await app.request('/v1/tokens/0x20c0000000000000000000000000000000000000') expect(token.status).toBe(404) }) })