Skip to content

Commit cceb650

Browse files
committed
feat: refactor Blockfrost API integration and enhance error handling in governance routes
1 parent ecc5b5b commit cceb650

7 files changed

Lines changed: 321 additions & 29 deletions

File tree

scripts/ci/README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -422,15 +422,15 @@ Bootstrap wallets and write host-mounted artifacts:
422422
```powershell
423423
docker compose -f docker-compose.ci.yml run --rm `
424424
-e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json `
425-
ci-runner npx --yes tsx scripts/ci/cli/bootstrap.ts
425+
ci-runner node .ci-dist/bootstrap.mjs
426426
```
427427

428428
Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails).
429429

430430
```powershell
431431
docker compose -f docker-compose.ci.yml run --rm `
432432
-e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json `
433-
ci-runner npx --yes tsx scripts/ci/cli/wallet-status.ts
433+
ci-runner node .ci-dist/wallet-status.mjs
434434
```
435435

436436
Run route-chain smoke scenarios:
@@ -439,7 +439,7 @@ Run route-chain smoke scenarios:
439439
docker compose -f docker-compose.ci.yml run --rm `
440440
-e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json `
441441
-e CI_ROUTE_CHAIN_REPORT_PATH=/artifacts/ci-route-chain-report.md `
442-
ci-runner npx --yes tsx scripts/ci/cli/route-chain.ts
442+
ci-runner node .ci-dist/route-chain.mjs
443443
444444
```
445445

@@ -512,15 +512,15 @@ Bootstrap wallets and write host-mounted artifacts:
512512
```bash
513513
docker compose -f docker-compose.ci.yml run --rm \
514514
-e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json \
515-
ci-runner npx --yes tsx scripts/ci/cli/bootstrap.ts
515+
ci-runner node .ci-dist/bootstrap.mjs
516516
```
517517

518518
Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails).
519519

520520
```bash
521521
docker compose -f docker-compose.ci.yml run --rm \
522522
-e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json \
523-
ci-runner npx --yes tsx scripts/ci/cli/wallet-status.ts
523+
ci-runner node .ci-dist/wallet-status.mjs
524524
```
525525

526526
Run route-chain smoke scenarios:
@@ -529,7 +529,7 @@ Run route-chain smoke scenarios:
529529
docker compose -f docker-compose.ci.yml run --rm \
530530
-e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json \
531531
-e CI_ROUTE_CHAIN_REPORT_PATH=/artifacts/ci-route-chain-report.md \
532-
ci-runner npx --yes tsx scripts/ci/cli/route-chain.ts
532+
ci-runner node .ci-dist/route-chain.mjs
533533
```
534534

535535
View generated report on host:

scripts/ci/scenarios/steps/governance.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function createScenarioGovernanceRoutes(ctx: CIBootstrapContext): Scenari
3333
sourceCount?: number;
3434
error?: string;
3535
}>({
36-
url: `${runCtx.apiBaseUrl}/api/v1/governanceActiveProposals?network=0&count=20&page=1&order=desc&details=false`,
36+
url: `${runCtx.apiBaseUrl}/api/v1/governanceActiveProposals?network=0&count=20&page=1&order=desc&details=false&debug=true`,
3737
method: "GET",
3838
token,
3939
});

src/__tests__/completeTxWithFreshCostModels.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ describe("refreshScriptDataHash", () => {
118118
costModelValues.length = 0;
119119
setScriptDataHashMock.mockClear();
120120
hashScriptDataMock.mockClear();
121+
delete process.env.BLOCKFROST_API_KEY_PREPROD;
122+
delete process.env.BLOCKFROST_API_KEY_MAINNET;
123+
delete process.env.CI_BLOCKFROST_PREPROD_API_KEY;
124+
delete process.env.CI_BLOCKFROST_MAINNET_API_KEY;
121125
MockTransaction.configure({ redeemerCount: 0 });
122126
});
123127

@@ -205,4 +209,34 @@ describe("refreshScriptDataHash", () => {
205209
),
206210
).toThrow(/cost_models_raw/);
207211
});
212+
213+
it("uses server-side Blockfrost env names when completing transactions", async () => {
214+
process.env.BLOCKFROST_API_KEY_PREPROD = "server-preprod-key";
215+
const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValue({
216+
ok: true,
217+
json: async () => ({ cost_models_raw: { PlutusV3: [10, 20] } }),
218+
} as Response);
219+
const { completeTxWithFreshCostModels } = await import(
220+
"@/lib/completeTxWithFreshCostModels"
221+
);
222+
223+
await expect(
224+
completeTxWithFreshCostModels(
225+
{
226+
complete: async () => "unsigned-tx-hex",
227+
} as never,
228+
0,
229+
),
230+
).resolves.toBe("unsigned-tx-hex");
231+
232+
expect(fetchMock).toHaveBeenCalledWith(
233+
"https://cardano-preprod.blockfrost.io/api/v0/epochs/latest/parameters",
234+
{
235+
headers: {
236+
project_id: "server-preprod-key",
237+
},
238+
},
239+
);
240+
fetchMock.mockRestore();
241+
});
208242
});

src/__tests__/governanceActiveProposals.test.ts

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse
88
const verifyJwtMock = jest.fn<() => unknown>();
99
const isBotJwtMock = jest.fn<() => boolean>();
1010
const findBotUserMock = jest.fn<() => Promise<unknown>>();
11+
const getProviderMock = jest.fn();
1112
const providerGetMock = jest.fn<(path: string) => Promise<unknown>>();
1213
const parseScopeMock = jest.fn<(scope: string) => string[]>();
1314
const scopeIncludesMock = jest.fn<(scopes: string[], required: string) => boolean>();
@@ -73,9 +74,7 @@ jest.unstable_mockModule(
7374
"@/utils/get-provider",
7475
() => ({
7576
__esModule: true,
76-
getProvider: () => ({
77-
get: providerGetMock,
78-
}),
77+
getProvider: getProviderMock,
7978
}),
8079
);
8180

@@ -125,6 +124,9 @@ beforeEach(() => {
125124
id: "bot-1",
126125
botKey: { scope: JSON.stringify(["multisig:read", "governance:read"]) },
127126
});
127+
getProviderMock.mockReturnValue({
128+
get: providerGetMock,
129+
});
128130
});
129131

130132
describe("governanceActiveProposals API", () => {
@@ -310,4 +312,144 @@ describe("governanceActiveProposals API", () => {
310312
});
311313
expect(payload.activeCount).toBe(1);
312314
});
315+
316+
it("falls back to direct Blockfrost REST when provider list fetch fails without a status", async () => {
317+
providerGetMock.mockImplementation(async (path) => {
318+
if (path.startsWith("/governance/proposals?")) {
319+
throw new Error("Internal Server Error");
320+
}
321+
if (path === "/governance/proposals/tx-active/0") {
322+
return {
323+
ratified_epoch: null,
324+
enacted_epoch: null,
325+
dropped_epoch: null,
326+
expired_epoch: null,
327+
expiration: 999,
328+
deposit: "1000000",
329+
return_address: "addr_test1...",
330+
};
331+
}
332+
if (path === "/governance/proposals/tx-active/0/metadata") {
333+
throw { status: 404 };
334+
}
335+
return null;
336+
});
337+
const originalKey = process.env.BLOCKFROST_API_KEY_PREPROD;
338+
process.env.BLOCKFROST_API_KEY_PREPROD = "preprod-key";
339+
const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue(
340+
new Response(
341+
JSON.stringify([
342+
{
343+
tx_hash: "tx-active",
344+
cert_index: 0,
345+
governance_type: "info_action",
346+
enacted_epoch: null,
347+
dropped_epoch: null,
348+
expired_epoch: null,
349+
ratified_epoch: null,
350+
},
351+
]),
352+
{ status: 200 },
353+
),
354+
);
355+
356+
try {
357+
const req = {
358+
method: "GET",
359+
headers: { authorization: "Bearer token" },
360+
query: { network: "0", count: "20", page: "1", order: "desc", details: "false" },
361+
} as unknown as NextApiRequest;
362+
const res = createMockResponse();
363+
364+
await handler(req, res);
365+
366+
expect(fetchSpy).toHaveBeenCalledWith(
367+
"https://cardano-preprod.blockfrost.io/api/v0/governance/proposals?count=20&page=1&order=desc",
368+
expect.objectContaining({
369+
headers: expect.objectContaining({ project_id: "preprod-key" }),
370+
}),
371+
);
372+
expect(res.status).toHaveBeenCalledWith(200);
373+
const payload = (res.json as unknown as jest.Mock).mock.calls[0]?.[0] as any;
374+
expect(payload.proposals).toHaveLength(1);
375+
expect(payload.activeCount).toBe(1);
376+
} finally {
377+
if (originalKey === undefined) {
378+
delete process.env.BLOCKFROST_API_KEY_PREPROD;
379+
} else {
380+
process.env.BLOCKFROST_API_KEY_PREPROD = originalKey;
381+
}
382+
fetchSpy.mockRestore();
383+
}
384+
});
385+
386+
it("falls back to direct Blockfrost REST when provider construction fails", async () => {
387+
getProviderMock.mockImplementation(() => {
388+
throw new TypeError("Cannot read properties of undefined (reading 'slice')");
389+
});
390+
const originalKey = process.env.BLOCKFROST_API_KEY_PREPROD;
391+
process.env.BLOCKFROST_API_KEY_PREPROD = "preprod-key";
392+
const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
393+
const urlString = String(url);
394+
if (urlString.includes("/governance/proposals?")) {
395+
return new Response(
396+
JSON.stringify([
397+
{
398+
tx_hash: "tx-active",
399+
cert_index: 0,
400+
governance_type: "info_action",
401+
enacted_epoch: null,
402+
dropped_epoch: null,
403+
expired_epoch: null,
404+
ratified_epoch: null,
405+
},
406+
]),
407+
{ status: 200 },
408+
);
409+
}
410+
if (urlString.includes("/governance/proposals/tx-active/0/metadata")) {
411+
return new Response(JSON.stringify({ error: "Not Found", status_code: 404 }), {
412+
status: 404,
413+
});
414+
}
415+
if (urlString.includes("/governance/proposals/tx-active/0")) {
416+
return new Response(
417+
JSON.stringify({
418+
ratified_epoch: null,
419+
enacted_epoch: null,
420+
dropped_epoch: null,
421+
expired_epoch: null,
422+
expiration: 999,
423+
deposit: "1000000",
424+
return_address: "addr_test1...",
425+
}),
426+
{ status: 200 },
427+
);
428+
}
429+
return new Response(JSON.stringify({ error: "Unexpected path" }), { status: 500 });
430+
});
431+
432+
try {
433+
const req = {
434+
method: "GET",
435+
headers: { authorization: "Bearer token" },
436+
query: { network: "0", count: "20", page: "1", order: "desc", details: "false" },
437+
} as unknown as NextApiRequest;
438+
const res = createMockResponse();
439+
440+
await handler(req, res);
441+
442+
expect(res.status).toHaveBeenCalledWith(200);
443+
const payload = (res.json as unknown as jest.Mock).mock.calls[0]?.[0] as any;
444+
expect(payload.proposals).toHaveLength(1);
445+
expect(payload.activeCount).toBe(1);
446+
} finally {
447+
if (originalKey === undefined) {
448+
delete process.env.BLOCKFROST_API_KEY_PREPROD;
449+
} else {
450+
process.env.BLOCKFROST_API_KEY_PREPROD = originalKey;
451+
}
452+
fetchSpy.mockRestore();
453+
}
454+
});
313455
});

src/lib/completeTxWithFreshCostModels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ function getBlockfrostProjectId(network: number): string {
2323
const projectId =
2424
network === 0
2525
? process.env.CI_BLOCKFROST_PREPROD_API_KEY?.trim() ||
26+
process.env.BLOCKFROST_API_KEY_PREPROD?.trim() ||
2627
env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD?.trim()
2728
: process.env.CI_BLOCKFROST_MAINNET_API_KEY?.trim() ||
29+
process.env.BLOCKFROST_API_KEY_MAINNET?.trim() ||
2830
env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET?.trim();
2931
if (!projectId) {
3032
throw new Error(`Missing Blockfrost project id for network ${network}`);

0 commit comments

Comments
 (0)