Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/mcp-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"smoke:sdk": "tsx scripts/smoke-sdk.ts"
},
"dependencies": {
"@gemini-markets/sdk": "^0.1.0",
"@gemini-markets/sdk": "^0.1.1",
"@modelcontextprotocol/sdk": "^1.27.1",
"json-bigint": "^1.0.0",
"node-notifier": "^10.0.1",
Expand Down
220 changes: 159 additions & 61 deletions packages/mcp-server/src/datasources/predictions/combos.test.ts
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',
Expand All @@ -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 () => {

Copy link
Copy Markdown
Contributor

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 contract or asserts legOutcome and resolvedAt; it only checks combo-level metadata. A regression in mapComboLeg would 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.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 leg.contract, so it never exercises the new metadata mapper on the nested leg object. When mapContractMetadata drops or misnames a leg field, this suite still passes while list/get/create tool output loses underlying contract metadata; add populated leg metadata and assert it for each response path.

},
],
},
],
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');
});
Loading
Loading