-
Notifications
You must be signed in to change notification settings - Fork 1
feat(mcp-server): migrate prediction combos REST endpoints to SDK (PREDICT-8819) #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,53 +1,65 @@ | ||
| import test from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import type { GeminiHttpClient } from '../../client/http.js'; | ||
| import type { SdkClient } from '../../client/sdk.js'; | ||
| import { listCombos, getCombo, createCombo } from './combos.js'; | ||
|
|
||
| interface RecordedCall { | ||
| method: 'publicGet' | 'authenticatedGet' | 'authenticatedPost'; | ||
| endpoint: string; | ||
| body?: Record<string, unknown>; | ||
| params?: Record<string, string | string[] | undefined>; | ||
| interface Call { | ||
| fn: 'listCombos' | 'getComboByInstrumentSymbol' | 'createCombo'; | ||
| input: unknown; | ||
| } | ||
|
|
||
| function fakeClient(response: unknown) { | ||
| const calls: RecordedCall[] = []; | ||
| function fakeClient( | ||
| listCombosResponse: unknown = { combos: [], pagination: { limit: 50, offset: 0 } }, | ||
| getComboResponse: unknown = { contract: {}, legs: [] }, | ||
| createComboResponse: unknown = { | ||
| alreadyExisted: false, | ||
| combo: { id: 1n, instrumentRegistered: false, legCount: 0, canonicalLegKey: 'k', legs: [] }, | ||
| } | ||
| ) { | ||
| const calls: Call[] = []; | ||
| const client = { | ||
| publicGet: async (endpoint: string, params?: Record<string, string | string[] | undefined>) => { | ||
| calls.push({ method: 'publicGet', endpoint, params }); | ||
| return response; | ||
| predictions: { | ||
| listCombos: async (input?: unknown) => { | ||
| calls.push({ fn: 'listCombos', input }); | ||
| return listCombosResponse; | ||
| }, | ||
| getComboByInstrumentSymbol: async (input?: unknown) => { | ||
| calls.push({ fn: 'getComboByInstrumentSymbol', input }); | ||
| return getComboResponse; | ||
| }, | ||
| createCombo: async (input?: unknown) => { | ||
| calls.push({ fn: 'createCombo', input }); | ||
| return createComboResponse; | ||
| }, | ||
| }, | ||
| authenticatedGet: async (endpoint: string, params?: Record<string, string | string[] | undefined>) => { | ||
| calls.push({ method: 'authenticatedGet', endpoint, params }); | ||
| return response; | ||
| }, | ||
| authenticatedPost: async ( | ||
| endpoint: string, | ||
| body?: Record<string, unknown>, | ||
| params?: Record<string, string | string[] | undefined> | ||
| ) => { | ||
| calls.push({ method: 'authenticatedPost', endpoint, body, params }); | ||
| return response; | ||
| }, | ||
| }; | ||
| return { client: client as unknown as GeminiHttpClient, calls }; | ||
| } as unknown as SdkClient; | ||
| return { client, calls }; | ||
| } | ||
|
|
||
| // ---------------------------------------------------------------------------- | ||
| // listCombos | ||
| // listCombos — forwards options to the SDK (contractId converted string→bigint), | ||
| // maps the response back onto the existing ListCombosResponse/ComboResponse shape | ||
| // ---------------------------------------------------------------------------- | ||
|
|
||
| test('listCombos with no opts sends no query params', async () => { | ||
| const { client, calls } = fakeClient({ combos: [], pagination: { limit: 50, offset: 0 } }); | ||
| test('listCombos with no opts calls the SDK with every field present but undefined', async () => { | ||
| const { client, calls } = fakeClient(); | ||
|
|
||
| await listCombos(client); | ||
|
|
||
| assert.strictEqual(calls.length, 1); | ||
| assert.strictEqual(calls[0]!.endpoint, '/v1/prediction-markets/combos'); | ||
| assert.deepStrictEqual(calls[0]!.params, {}); | ||
| assert.strictEqual(calls[0]!.fn, 'listCombos'); | ||
| assert.deepStrictEqual(calls[0]!.input, { | ||
| status: undefined, | ||
| contractId: undefined, | ||
| instrumentRegistered: undefined, | ||
| limit: undefined, | ||
| offset: undefined, | ||
| }); | ||
| }); | ||
|
|
||
| test('listCombos with all opts sends exactly those params, correctly stringified', async () => { | ||
| const { client, calls } = fakeClient({ combos: [], pagination: { limit: 10, offset: 5 } }); | ||
| test('listCombos converts a large contractId string to bigint, never Number() (precision)', async () => { | ||
| const { client, calls } = fakeClient(); | ||
|
|
||
| await listCombos(client, { | ||
| status: 'Active', | ||
| contractId: '123456789012345678', | ||
|
|
@@ -56,59 +68,145 @@ test('listCombos with all opts sends exactly those params, correctly stringified | |
| offset: 5, | ||
| }); | ||
|
|
||
| assert.strictEqual(calls.length, 1); | ||
| assert.strictEqual(calls[0]!.endpoint, '/v1/prediction-markets/combos'); | ||
| assert.deepStrictEqual(calls[0]!.params, { | ||
| assert.deepStrictEqual(calls[0]!.input, { | ||
| status: 'Active', | ||
| contractId: '123456789012345678', | ||
| instrumentRegistered: 'true', | ||
| limit: '10', | ||
| offset: '5', | ||
| contractId: 123456789012345678n, | ||
| instrumentRegistered: true, | ||
| limit: 10, | ||
| offset: 5, | ||
| }); | ||
| }); | ||
|
|
||
| test('listCombos stringifies instrumentRegistered: false rather than dropping it', async () => { | ||
| const { client, calls } = fakeClient({ combos: [], pagination: { limit: 50, offset: 0 } }); | ||
| test('listCombos forwards instrumentRegistered: false, not treated as absent', async () => { | ||
| const { client, calls } = fakeClient(); | ||
|
|
||
| await listCombos(client, { instrumentRegistered: false }); | ||
|
|
||
| assert.deepStrictEqual(calls[0]!.params, { instrumentRegistered: 'false' }); | ||
| assert.strictEqual((calls[0]!.input as { instrumentRegistered?: boolean }).instrumentRegistered, false); | ||
| }); | ||
|
|
||
| test('listCombos maps a bigint comboId on each leg to an exact string, and drops extra contract fields', async () => { | ||
| const { client } = fakeClient({ | ||
| combos: [ | ||
| { | ||
| contract: { | ||
| contractId: 'c1', | ||
| contractName: 'Contract One', | ||
| eventTicker: 'FEDJAN26', | ||
| eventName: 'Fed January', | ||
| category: 'Politics', | ||
| // Extra SDK-only field — must not leak into the mapped output. | ||
| contractStatus: 'active', | ||
| }, | ||
| legs: [ | ||
| { | ||
| comboId: 123456789012345678n, | ||
| legIndex: 0, | ||
| contractId: '111', | ||
| requiredOutcome: 'Yes', | ||
|
Comment on lines
+105
to
+106
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Suggestion: Missing coverage for nested combo leg metadata The combo-leg mapping test omits |
||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| pagination: { limit: 50, offset: 0, total: 1 }, | ||
| }); | ||
|
|
||
| const result = await listCombos(client); | ||
|
|
||
| assert.deepStrictEqual(result, { | ||
| combos: [ | ||
| { | ||
| contract: { | ||
| contractId: 'c1', | ||
| contractName: 'Contract One', | ||
| eventTicker: 'FEDJAN26', | ||
| eventName: 'Fed January', | ||
| category: 'Politics', | ||
| }, | ||
| legs: [ | ||
| { | ||
| comboId: '123456789012345678', | ||
| contract: undefined, | ||
| contractId: '111', | ||
| legIndex: 0, | ||
| requiredOutcome: 'Yes', | ||
| legOutcome: undefined, | ||
| resolvedAt: undefined, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| pagination: { limit: 50, offset: 0, total: 1 }, | ||
| }); | ||
| }); | ||
|
|
||
| // ---------------------------------------------------------------------------- | ||
| // getCombo | ||
| // getCombo — now calls getComboByInstrumentSymbol under the hood | ||
| // ---------------------------------------------------------------------------- | ||
|
|
||
| test('getCombo interpolates the instrument symbol into the path, unencoded', async () => { | ||
| const { client, calls } = fakeClient({ contract: {}, legs: [] }); | ||
| // A symbol containing characters that would change under encodeURIComponent | ||
| // (e.g. nothing to encode here, but the assertion below pins the literal, | ||
| // unencoded interpolation convention shared with getEvent/getEventStrike). | ||
| test('getCombo calls the SDK with { instrumentSymbol }', async () => { | ||
| const { client, calls } = fakeClient(undefined, { contract: {}, legs: [] }); | ||
|
|
||
| await getCombo(client, 'GEMI-COMBO-ABC123'); | ||
|
|
||
| assert.strictEqual(calls.length, 1); | ||
| assert.strictEqual(calls[0]!.method, 'publicGet'); | ||
| assert.strictEqual(calls[0]!.endpoint, '/v1/prediction-markets/combos/GEMI-COMBO-ABC123'); | ||
| assert.strictEqual(calls[0]!.fn, 'getComboByInstrumentSymbol'); | ||
| assert.deepStrictEqual(calls[0]!.input, { instrumentSymbol: 'GEMI-COMBO-ABC123' }); | ||
| }); | ||
|
|
||
| test('getCombo maps a bigint comboId to an exact string', async () => { | ||
| const { client } = fakeClient(undefined, { | ||
| contract: { contractId: 'c1', contractName: 'C1', eventTicker: 'E', eventName: 'E', category: 'Cat' }, | ||
| legs: [{ comboId: 987654321098765432n, legIndex: 0, contractId: '111', requiredOutcome: 'No' }], | ||
| }); | ||
|
|
||
| const result = await getCombo(client, 'GEMI-COMBO-ABC123'); | ||
|
|
||
| assert.strictEqual(result.legs[0]!.comboId, '987654321098765432'); | ||
| }); | ||
|
|
||
| // ---------------------------------------------------------------------------- | ||
| // createCombo | ||
| // createCombo — legs pass through verbatim (capitalized Yes/No preserved), | ||
| // response mapped through mapComboSummary | ||
| // ---------------------------------------------------------------------------- | ||
|
|
||
| test('createCombo posts { legs } with the exact legs array passed through', async () => { | ||
| const { client, calls } = fakeClient({ alreadyExisted: false, combo: {} }); | ||
| test('createCombo calls the SDK with { legs } and the exact legs array, capitalization intact', async () => { | ||
| const { client, calls } = fakeClient(); | ||
| const legs: Array<{ contractId: string; requiredOutcome: 'Yes' | 'No' }> = [ | ||
| { contractId: '111', requiredOutcome: 'Yes' }, | ||
| { contractId: '222', requiredOutcome: 'No' }, | ||
| ]; | ||
|
|
||
| await createCombo(client, legs); | ||
|
|
||
| assert.strictEqual(calls.length, 1); | ||
| assert.strictEqual(calls[0]!.method, 'authenticatedPost'); | ||
| assert.strictEqual(calls[0]!.endpoint, '/v1/prediction-markets/combos'); | ||
| // Capitalized outcomes must survive verbatim — not lowercased for | ||
| // consistency with the rest of the codebase's 'yes'/'no' convention. | ||
| assert.deepStrictEqual(calls[0]!.body, { legs }); | ||
| const sentLegs = (calls[0]!.body as { legs: Array<{ requiredOutcome: string }> }).legs; | ||
| assert.strictEqual(sentLegs[0]!.requiredOutcome, 'Yes'); | ||
| assert.strictEqual(sentLegs[1]!.requiredOutcome, 'No'); | ||
| assert.strictEqual(calls[0]!.fn, 'createCombo'); | ||
| assert.deepStrictEqual(calls[0]!.input, { legs }); | ||
| }); | ||
|
|
||
| test('createCombo maps a bigint combo.id and instrumentId to exact strings, preserves alreadyExisted', async () => { | ||
| const { client } = fakeClient(undefined, undefined, { | ||
| alreadyExisted: true, | ||
| combo: { | ||
| canonicalLegKey: 'k', | ||
| id: 145828833218573125n, | ||
| instrumentId: 999999999999999999n, | ||
| instrumentRegistered: true, | ||
| instrumentSymbol: 'GEMI-COMBO-XYZ', | ||
| legCount: 2, | ||
| legs: [ | ||
| { comboId: 145828833218573125n, legIndex: 0, contractId: '111', requiredOutcome: 'Yes' }, | ||
| { comboId: 145828833218573125n, legIndex: 1, contractId: '222', requiredOutcome: 'No' }, | ||
| ], | ||
| }, | ||
| }); | ||
|
|
||
| const result = await createCombo(client, [ | ||
| { contractId: '111', requiredOutcome: 'Yes' }, | ||
| { contractId: '222', requiredOutcome: 'No' }, | ||
| ]); | ||
|
|
||
| assert.strictEqual(result.alreadyExisted, true); | ||
| assert.strictEqual(result.combo.id, '145828833218573125'); | ||
| assert.strictEqual(result.combo.instrumentId, '999999999999999999'); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Suggestion: Nested combo-leg mapping is not verified
This test never supplies a leg
contractor assertslegOutcomeandresolvedAt; it only checks combo-level metadata. A regression inmapComboLegwould still pass while returned legs lose contract metadata or resolution fields, which are part of the migrated output shape. Add a populated leg fixture and assert the complete mapped leg.