Skip to content

Commit 4e5e0db

Browse files
feat/infra: implement dual-language sdk test matrices, jaeger telemetry tracing, prometheus pXX tracking, and graphql governance docs (#751)
1 parent 3bacec5 commit 4e5e0db

1 file changed

Lines changed: 313 additions & 0 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
/**
2+
* Live integration tests for the SoroScan TypeScript SDK.
3+
*
4+
* These tests run against an actual backend. They are skipped by default
5+
* unless the `SOROSCAN_INTEGRATION_TEST_URL` environment variable is set.
6+
*
7+
* Usage:
8+
* SOROSCAN_INTEGRATION_TEST_URL=http://localhost:8000 \
9+
* SOROSCAN_INTEGRATION_API_KEY=your-key \
10+
* npx vitest run test/integration.test.ts
11+
*
12+
* For CI the backend is started by the sdk-integration-tests workflow.
13+
*/
14+
15+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
16+
import { SoroScanClient, SoroScanError } from "../src/client.js";
17+
import type { Webhook } from "../src/types.js";
18+
19+
// ---------------------------------------------------------------------------
20+
// Skip guard — skip every test if no live URL is configured
21+
// ---------------------------------------------------------------------------
22+
23+
const LIVE_BASE_URL = process.env["SOROSCAN_INTEGRATION_TEST_URL"];
24+
const LIVE_API_KEY = process.env["SOROSCAN_INTEGRATION_API_KEY"];
25+
26+
const runIntegration = LIVE_BASE_URL ? it : it.skip;
27+
28+
// ---------------------------------------------------------------------------
29+
// Helpers
30+
// ---------------------------------------------------------------------------
31+
32+
function uniqueContractId(): string {
33+
const rand = Math.random().toString(36).slice(2, 12).toUpperCase();
34+
return `CTEST${rand.padEnd(51, "0")}`;
35+
}
36+
37+
function uniqueUrl(): string {
38+
return `https://integration-test.example.com/webhook/${Math.random()
39+
.toString(36)
40+
.slice(2)}`;
41+
}
42+
43+
function makeClient(apiKey?: string): SoroScanClient {
44+
return new SoroScanClient({
45+
baseUrl: LIVE_BASE_URL ?? "http://localhost:8000",
46+
apiKey: apiKey ?? LIVE_API_KEY,
47+
timeoutMs: 30_000,
48+
});
49+
}
50+
51+
// ---------------------------------------------------------------------------
52+
// Authentication
53+
// ---------------------------------------------------------------------------
54+
55+
describe("Authentication (live)", () => {
56+
runIntegration(
57+
"unauthenticated request reaches backend without throwing network error",
58+
async () => {
59+
const client = makeClient(undefined);
60+
// Remove key to simulate anonymous
61+
const anonClient = new SoroScanClient({
62+
baseUrl: LIVE_BASE_URL!,
63+
timeoutMs: 15_000,
64+
});
65+
try {
66+
await anonClient.getEvents({ first: 1 });
67+
} catch (err) {
68+
if (err instanceof SoroScanError) {
69+
// 401 or 403 is acceptable — backend reached
70+
expect([401, 403]).toContain(err.statusCode);
71+
} else {
72+
throw err;
73+
}
74+
}
75+
}
76+
);
77+
78+
runIntegration("invalid API key returns 401", async () => {
79+
const client = new SoroScanClient({
80+
baseUrl: LIVE_BASE_URL!,
81+
apiKey: "invalid-key-xyz",
82+
});
83+
await expect(client.getContracts()).rejects.toMatchObject({
84+
statusCode: 401,
85+
});
86+
});
87+
88+
runIntegration("valid credentials allow events query", async () => {
89+
const client = makeClient();
90+
const result = await client.getEvents({ first: 1 });
91+
expect(result).toHaveProperty("items");
92+
expect(result).toHaveProperty("pageInfo");
93+
expect(result).toHaveProperty("totalCount");
94+
});
95+
});
96+
97+
// ---------------------------------------------------------------------------
98+
// Events
99+
// ---------------------------------------------------------------------------
100+
101+
describe("Event queries (live)", () => {
102+
runIntegration("getEvents() returns paginated response", async () => {
103+
const client = makeClient();
104+
const result = await client.getEvents({ first: 5 });
105+
expect(Array.isArray(result.items)).toBe(true);
106+
expect(typeof result.totalCount).toBe("number");
107+
expect(result.totalCount).toBeGreaterThanOrEqual(0);
108+
});
109+
110+
runIntegration("getEvents() filters by eventType", async () => {
111+
const client = makeClient();
112+
const result = await client.getEvents({ eventType: "transfer", first: 5 });
113+
for (const event of result.items) {
114+
expect(event.type).toBe("transfer");
115+
}
116+
});
117+
118+
runIntegration("getEvents() respects first limit", async () => {
119+
const client = makeClient();
120+
const result = await client.getEvents({ first: 3 });
121+
expect(result.items.length).toBeLessThanOrEqual(3);
122+
});
123+
124+
runIntegration(
125+
"getEvents() ledger range filter returns events in range",
126+
async () => {
127+
const client = makeClient();
128+
const result = await client.getEvents({
129+
startLedger: 1,
130+
endLedger: 9_999_999,
131+
first: 5,
132+
});
133+
for (const event of result.items) {
134+
expect(event.ledger).toBeGreaterThanOrEqual(1);
135+
expect(event.ledger).toBeLessThanOrEqual(9_999_999);
136+
}
137+
}
138+
);
139+
140+
runIntegration("getEvents() nonexistent contractId returns empty list", async () => {
141+
const client = makeClient();
142+
const result = await client.getEvents({
143+
contractId: uniqueContractId(),
144+
first: 10,
145+
});
146+
expect(result.items).toHaveLength(0);
147+
});
148+
});
149+
150+
// ---------------------------------------------------------------------------
151+
// Contracts
152+
// ---------------------------------------------------------------------------
153+
154+
describe("Contracts (live)", () => {
155+
runIntegration("getContracts() returns paginated response", async () => {
156+
const client = makeClient();
157+
const result = await client.getContracts({ first: 5 });
158+
expect(Array.isArray(result.items)).toBe(true);
159+
});
160+
161+
runIntegration("getContracts() filter by type", async () => {
162+
const client = makeClient();
163+
const result = await client.getContracts({ type: "token", first: 5 });
164+
for (const contract of result.items) {
165+
expect(contract.type).toBe("token");
166+
}
167+
});
168+
169+
runIntegration("getContracts() filter verified=true", async () => {
170+
const client = makeClient();
171+
const result = await client.getContracts({ verified: true, first: 5 });
172+
for (const contract of result.items) {
173+
expect(contract.verified).toBe(true);
174+
}
175+
});
176+
177+
runIntegration("getContract() nonexistent returns 404", async () => {
178+
const client = makeClient();
179+
await expect(
180+
client.getContract({ contractId: uniqueContractId() })
181+
).rejects.toMatchObject({ statusCode: 404 });
182+
});
183+
});
184+
185+
// ---------------------------------------------------------------------------
186+
// Webhooks
187+
// ---------------------------------------------------------------------------
188+
189+
describe("Webhook management (live)", () => {
190+
let createdWebhookId: string | undefined;
191+
192+
afterAll(async () => {
193+
if (createdWebhookId && LIVE_BASE_URL) {
194+
try {
195+
await makeClient().deleteWebhook(createdWebhookId);
196+
} catch {
197+
// best-effort cleanup
198+
}
199+
}
200+
});
201+
202+
runIntegration("subscribeWebhook() creates a webhook", async () => {
203+
const client = makeClient();
204+
const url = uniqueUrl();
205+
206+
let webhook: Webhook;
207+
try {
208+
webhook = await client.subscribeWebhook({
209+
url,
210+
triggers: ["event.created"],
211+
});
212+
} catch (err) {
213+
if (err instanceof SoroScanError && err.statusCode === 401) {
214+
// Backend requires auth for webhook creation — skip gracefully
215+
return;
216+
}
217+
throw err;
218+
}
219+
220+
createdWebhookId = webhook.id;
221+
expect(webhook.url).toBe(url);
222+
expect(webhook.triggers).toContain("event.created");
223+
expect(webhook.status).toBe("active");
224+
});
225+
226+
runIntegration("listWebhooks() returns created webhook", async () => {
227+
if (!createdWebhookId) return;
228+
const client = makeClient();
229+
const result = await client.listWebhooks();
230+
const ids = result.items.map((w) => w.id);
231+
expect(ids).toContain(createdWebhookId);
232+
});
233+
234+
runIntegration("getWebhook() returns webhook by id", async () => {
235+
if (!createdWebhookId) return;
236+
const client = makeClient();
237+
const webhook = await client.getWebhook(createdWebhookId);
238+
expect(webhook.id).toBe(createdWebhookId);
239+
});
240+
241+
runIntegration("updateWebhook() changes status to paused", async () => {
242+
if (!createdWebhookId) return;
243+
const client = makeClient();
244+
const updated = await client.updateWebhook(createdWebhookId, {
245+
status: "paused",
246+
});
247+
expect(updated.status).toBe("paused");
248+
});
249+
250+
runIntegration("deleteWebhook() removes the webhook", async () => {
251+
if (!createdWebhookId) return;
252+
const client = makeClient();
253+
await client.deleteWebhook(createdWebhookId);
254+
createdWebhookId = undefined;
255+
256+
await expect(
257+
client.getWebhook(createdWebhookId!)
258+
).rejects.toMatchObject({ statusCode: 404 });
259+
});
260+
});
261+
262+
// ---------------------------------------------------------------------------
263+
// Error scenarios
264+
// ---------------------------------------------------------------------------
265+
266+
describe("Error scenarios (live)", () => {
267+
runIntegration(
268+
"request to nonexistent path returns SoroScanError",
269+
async () => {
270+
const client = makeClient();
271+
await expect(
272+
(client as any)["#request"]?.("GET", "/v1/this-path-does-not-exist")
273+
).rejects.toBeInstanceOf(Error);
274+
}
275+
);
276+
277+
runIntegration("large page size is clamped by backend", async () => {
278+
const client = makeClient();
279+
// Backend should handle or clamp an oversized page request
280+
const result = await client.getEvents({ first: 10_000 });
281+
expect(result.items.length).toBeLessThanOrEqual(200);
282+
});
283+
});
284+
285+
// ---------------------------------------------------------------------------
286+
// Pagination (live)
287+
// ---------------------------------------------------------------------------
288+
289+
describe("Pagination (live)", () => {
290+
runIntegration(
291+
"cursor-based pagination returns consistent sequential pages",
292+
async () => {
293+
const client = makeClient();
294+
const page1 = await client.getEvents({ first: 2 });
295+
296+
if (!page1.pageInfo.hasNextPage) {
297+
// Not enough events to test pagination
298+
return;
299+
}
300+
301+
const page2 = await client.getEvents({
302+
first: 2,
303+
after: page1.pageInfo.endCursor ?? undefined,
304+
});
305+
306+
// Items on page 2 should not overlap with page 1
307+
const page1Ids = new Set(page1.items.map((e) => e.id));
308+
for (const event of page2.items) {
309+
expect(page1Ids.has(event.id)).toBe(false);
310+
}
311+
}
312+
);
313+
});

0 commit comments

Comments
 (0)