Skip to content

Commit b594ee7

Browse files
committed
inscription atlas: tests for first-image resolver, route dispatch, quadtree
53 new tests across three files: - ordpool-quadtree-allocator.spec.ts (frontend, 18 tests): bud/insert/remove paths, full-flag propagation, parent collapse on free, packSlot encoding with shader-mirrored decode round-trip, packing-pressure stress that fills the atlas with 1024 minimum-size slots and asserts no rectangle overlap. - ordpool-inscriptions.api.test.ts (+15 tests): the `$getFirstImageInscription` resolver. Covers single-image txs, batch reveals where the image sits at index 1 or 2 behind JSON/text, multi-image first-wins, every common image MIME variant we serve, all rejection cases (non-image content types, delegate stubs with no contentType, empty inscription list, 404 from RPC, non-404 rethrow), mempool short-circuit, delegate handoff, recursion cap, and the skipConversion=false call-shape regression guard. - ordpool.routes.content-dispatch.test.ts (frontend-driven, 17 tests): the isBareTxid regex (10 cases including uppercase/lowercase/mixed hex, multi-digit indices, off-by-one lengths, non-hex), and the route handler's dispatch (bare txid -> first-image, txid+iN -> existing resolver, both 400/404/500 paths).
1 parent b066c9f commit b594ee7

4 files changed

Lines changed: 643 additions & 4 deletions

File tree

backend/src/api/explorer/_ordpool/ordpool-inscriptions.api.test.ts

Lines changed: 193 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { InscriptionParserService } from 'ordpool-parser';
2+
13
import bitcoinApi from '../../bitcoin/bitcoin-api-factory';
24
import memPool from '../../mempool';
35
import ordpoolInscriptionsApi from './ordpool-inscriptions.api';
@@ -15,9 +17,18 @@ jest.mock('../../mempool', () => ({
1517
__esModule: true,
1618
default: { getMempool: jest.fn() },
1719
}));
18-
// ordpool-parser is left unmocked: isValidInscriptionId is pure regex
19-
// validation, and InscriptionParserService.parse on a fake tx with no
20-
// witness data returns [] without side effects.
20+
// Synthetic ParsedInscription shape — only the fields the API code under
21+
// test reads. Tests for $getFirstImageInscription install
22+
// jest.spyOn(InscriptionParserService, 'parse') and hand-craft these.
23+
function fakeInscription(overrides: { contentType?: string; delegates?: string[] } = {}): any {
24+
return {
25+
contentType: overrides.contentType,
26+
getDelegates: () => overrides.delegates || [],
27+
contentSize: 0,
28+
getContentEncoding: () => undefined,
29+
getDataRaw: () => new Uint8Array(),
30+
};
31+
}
2132

2233
// Real mainnet txid (image/png inscription); used here for shape only,
2334
// the parser sees a stub transaction we hand it via the mocked bitcoinApi.
@@ -86,3 +97,182 @@ describe('OrdpoolInscriptionsApi.$getInscriptionById call shape', () => {
8697
).rejects.toThrow('connection refused');
8798
});
8899
});
100+
101+
describe('OrdpoolInscriptionsApi.$getFirstImageInscription', () => {
102+
103+
let parseSpy: jest.SpyInstance;
104+
105+
beforeEach(() => {
106+
jest.resetAllMocks();
107+
parseSpy = jest.spyOn(InscriptionParserService, 'parse');
108+
(memPool.getMempool as jest.Mock).mockReturnValue({});
109+
(bitcoinApi.$getRawTransaction as jest.Mock).mockResolvedValue({
110+
txid: VALID_TXID,
111+
vin: [{ witness: [], scriptsig: '' }],
112+
vout: [{ scriptpubkey: '', value: 0 }],
113+
});
114+
});
115+
116+
afterEach(() => {
117+
parseSpy.mockRestore();
118+
});
119+
120+
it('returns the only inscription when the tx has a single image', async () => {
121+
const image = fakeInscription({ contentType: 'image/png' });
122+
parseSpy.mockReturnValue([image]);
123+
124+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
125+
126+
expect(result).toBe(image);
127+
});
128+
129+
it('skips a JSON inscription at index 0 and returns the image at index 1', async () => {
130+
// Batch reveal: the parser sets ordpool_inscription_image because index 1
131+
// is image/png, but a flat /content/<txid>i0 lookup hits the JSON. The
132+
// first-image resolver is the fix.
133+
const json = fakeInscription({ contentType: 'application/json' });
134+
const image = fakeInscription({ contentType: 'image/webp' });
135+
parseSpy.mockReturnValue([json, image]);
136+
137+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
138+
139+
expect(result).toBe(image);
140+
});
141+
142+
it('skips text and JSON to return the image at index 2', async () => {
143+
const text = fakeInscription({ contentType: 'text/plain' });
144+
const json = fakeInscription({ contentType: 'application/json' });
145+
const image = fakeInscription({ contentType: 'image/gif' });
146+
parseSpy.mockReturnValue([text, json, image]);
147+
148+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
149+
150+
expect(result).toBe(image);
151+
});
152+
153+
it('returns the FIRST image when multiple images are present', async () => {
154+
const a = fakeInscription({ contentType: 'image/jpeg' });
155+
const b = fakeInscription({ contentType: 'image/png' });
156+
parseSpy.mockReturnValue([a, b]);
157+
158+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
159+
160+
expect(result).toBe(a);
161+
});
162+
163+
it('matches every common image MIME variant we serve', async () => {
164+
for (const contentType of ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'image/avif']) {
165+
const ins = fakeInscription({ contentType });
166+
parseSpy.mockReturnValue([ins]);
167+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
168+
expect(result).toBe(ins);
169+
}
170+
});
171+
172+
it('does NOT match non-image content types', async () => {
173+
for (const contentType of ['application/json', 'text/plain', 'text/html', 'application/octet-stream', 'video/mp4', 'audio/mpeg']) {
174+
const ins = fakeInscription({ contentType });
175+
parseSpy.mockReturnValue([ins]);
176+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
177+
expect(result).toBeUndefined();
178+
}
179+
});
180+
181+
it('treats inscriptions with no contentType (delegate stubs) as non-image', async () => {
182+
const stub = fakeInscription({ contentType: undefined });
183+
parseSpy.mockReturnValue([stub]);
184+
185+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
186+
187+
expect(result).toBeUndefined();
188+
});
189+
190+
it('returns undefined when the tx contains no inscriptions at all', async () => {
191+
parseSpy.mockReturnValue([]);
192+
193+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
194+
195+
expect(result).toBeUndefined();
196+
});
197+
198+
it('returns undefined when the tx is not on chain (bitcoin API 404)', async () => {
199+
(bitcoinApi.$getRawTransaction as jest.Mock).mockRejectedValue(
200+
Object.assign(new Error('not found'), { response: { status: 404 } }),
201+
);
202+
203+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
204+
205+
expect(result).toBeUndefined();
206+
});
207+
208+
it('rethrows non-404 errors from the bitcoin API', async () => {
209+
(bitcoinApi.$getRawTransaction as jest.Mock).mockRejectedValue(new Error('connection refused'));
210+
211+
await expect(
212+
ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID),
213+
).rejects.toThrow('connection refused');
214+
});
215+
216+
it('uses the mempool entry when present and skips the bitcoin API', async () => {
217+
const mempoolTx = { txid: VALID_TXID, vin: [{ witness: [], scriptsig: '' }], vout: [] };
218+
(memPool.getMempool as jest.Mock).mockReturnValue({ [VALID_TXID]: mempoolTx });
219+
const image = fakeInscription({ contentType: 'image/png' });
220+
parseSpy.mockReturnValue([image]);
221+
222+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
223+
224+
expect(bitcoinApi.$getRawTransaction).not.toHaveBeenCalled();
225+
expect(parseSpy).toHaveBeenCalledWith(mempoolTx);
226+
expect(result).toBe(image);
227+
});
228+
229+
it('resolves a delegate when the first image points at one', async () => {
230+
// The image we find has a delegate; the resolver should chase it via the
231+
// existing $getInscriptionOrDelegeate path and return the delegate's
232+
// inscription. We mock that downstream call directly to keep the test
233+
// focused on the delegate-handoff edge.
234+
const imageWithDelegate = fakeInscription({
235+
contentType: 'image/png',
236+
delegates: ['aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111i0'],
237+
});
238+
parseSpy.mockReturnValue([imageWithDelegate]);
239+
240+
const delegated = fakeInscription({ contentType: 'image/svg+xml' });
241+
const delegateSpy = jest
242+
.spyOn(ordpoolInscriptionsApi, '$getInscriptionOrDelegeate')
243+
.mockResolvedValue(delegated as any);
244+
245+
const result = await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
246+
247+
expect(delegateSpy).toHaveBeenCalledWith(
248+
'aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111i0',
249+
1, // recursive level incremented
250+
);
251+
expect(result).toBe(delegated);
252+
delegateSpy.mockRestore();
253+
});
254+
255+
it('throws after 4 levels of delegate recursion', async () => {
256+
const looping = fakeInscription({
257+
contentType: 'image/png',
258+
delegates: ['bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222i0'],
259+
});
260+
parseSpy.mockReturnValue([looping]);
261+
262+
await expect(
263+
ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID, 5),
264+
).rejects.toThrow('Too many delegate levels');
265+
});
266+
267+
it('passes skipConversion=false to the bitcoin API (Esplora-shape conversion regression guard)', async () => {
268+
const image = fakeInscription({ contentType: 'image/png' });
269+
parseSpy.mockReturnValue([image]);
270+
271+
await ordpoolInscriptionsApi.$getFirstImageInscription(VALID_TXID);
272+
273+
expect(bitcoinApi.$getRawTransaction).toHaveBeenCalledTimes(1);
274+
const args = (bitcoinApi.$getRawTransaction as jest.Mock).mock.calls[0];
275+
expect(args[0]).toBe(VALID_TXID);
276+
expect(args[1]).toBe(false); // skipConversion MUST be false
277+
});
278+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { Request, Response } from 'express';
2+
3+
import generalOrdpoolRoutes, { isBareTxid } from './ordpool.routes';
4+
import ordpoolInscriptionsApi from './ordpool-inscriptions.api';
5+
import { InscriptionPreviewService } from 'ordpool-parser';
6+
7+
// Same factory-mock pattern as ordpool.routes.test.ts: short-circuit the
8+
// module-load chain so the suite boots without a real config file.
9+
jest.mock('../../blocks', () => ({ __esModule: true, default: { getCurrentBlockHeight: jest.fn() } }));
10+
jest.mock('../../ordpool-missing-stats', () => ({ __esModule: true, default: { getLastSuccessAt: jest.fn(), getBlocksPerMinute: jest.fn() } }));
11+
jest.mock('../../../repositories/OrdpoolBlocksRepository', () => ({ __esModule: true, default: {} }));
12+
jest.mock('../../../repositories/OrdpoolSkippedBlocksRepository', () => ({ __esModule: true, default: {} }));
13+
jest.mock('./ordpool-inscriptions.api', () => ({
14+
__esModule: true,
15+
default: {
16+
$getInscriptionOrDelegeate: jest.fn(),
17+
$getFirstImageInscription: jest.fn(),
18+
},
19+
}));
20+
jest.mock('./ordpool-statistics.api', () => ({ __esModule: true, default: {} }));
21+
jest.mock('../../../config', () => ({
22+
__esModule: true,
23+
default: { MEMPOOL: { NETWORK: 'mainnet', API_URL_PREFIX: '/api/v1/' } },
24+
}));
25+
26+
const TXID = '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799';
27+
28+
function makeRes() {
29+
const res: Partial<Response> = {};
30+
res.setHeader = jest.fn();
31+
res.status = jest.fn().mockImplementation(() => res);
32+
res.json = jest.fn().mockImplementation(() => res);
33+
res.send = jest.fn().mockImplementation(() => res);
34+
return res as Response & { status: jest.Mock; send: jest.Mock; setHeader: jest.Mock };
35+
}
36+
37+
function fakeInscription(): any {
38+
return {
39+
contentType: 'image/png',
40+
contentSize: 0,
41+
getContentEncoding: () => undefined,
42+
getDataRaw: () => new Uint8Array(),
43+
getDelegates: () => [],
44+
};
45+
}
46+
47+
describe('isBareTxid', () => {
48+
it.each([
49+
['64 lowercase hex chars', '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799', true],
50+
['64 uppercase hex chars', 'A'.repeat(64), true],
51+
['64 mixed-case hex chars', 'aBcDeF12'.repeat(8), true],
52+
['txid with i0 suffix', '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i0', false],
53+
['txid with i37 suffix', '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i37', false],
54+
['63 hex chars (one short)', 'a'.repeat(63), false],
55+
['65 hex chars (one too many)', 'a'.repeat(65), false],
56+
['64 chars with one non-hex letter', 'g' + 'a'.repeat(63), false],
57+
['empty string', '', false],
58+
['just an integer index', '0', false],
59+
])('%s -> %s', (_label, value, expected) => {
60+
expect(isBareTxid(value)).toBe(expected);
61+
});
62+
});
63+
64+
describe('getInscriptionContent route handler dispatch', () => {
65+
66+
beforeEach(() => {
67+
jest.resetAllMocks();
68+
// The fragment shader / atlas test plan: bare txid path must reach
69+
// $getFirstImageInscription, full inscription-id path must reach the
70+
// existing $getInscriptionOrDelegeate. Each test verifies one branch.
71+
});
72+
73+
it('routes a bare txid to $getFirstImageInscription', async () => {
74+
(ordpoolInscriptionsApi.$getFirstImageInscription as jest.Mock).mockResolvedValue(fakeInscription());
75+
const req = { params: { inscriptionId: TXID } } as unknown as Request;
76+
const res = makeRes();
77+
78+
await (generalOrdpoolRoutes as any).getInscriptionContent(req, res);
79+
80+
expect(ordpoolInscriptionsApi.$getFirstImageInscription).toHaveBeenCalledWith(TXID);
81+
expect(ordpoolInscriptionsApi.$getInscriptionOrDelegeate).not.toHaveBeenCalled();
82+
expect(res.status).toHaveBeenCalledWith(200);
83+
});
84+
85+
it('routes a txid+iN to $getInscriptionOrDelegeate', async () => {
86+
(ordpoolInscriptionsApi.$getInscriptionOrDelegeate as jest.Mock).mockResolvedValue(fakeInscription());
87+
const req = { params: { inscriptionId: `${TXID}i0` } } as unknown as Request;
88+
const res = makeRes();
89+
90+
await (generalOrdpoolRoutes as any).getInscriptionContent(req, res);
91+
92+
expect(ordpoolInscriptionsApi.$getInscriptionOrDelegeate).toHaveBeenCalledWith(`${TXID}i0`);
93+
expect(ordpoolInscriptionsApi.$getFirstImageInscription).not.toHaveBeenCalled();
94+
expect(res.status).toHaveBeenCalledWith(200);
95+
});
96+
97+
it('routes txid+i37 to $getInscriptionOrDelegeate (multi-digit indices stay on the inscription path)', async () => {
98+
(ordpoolInscriptionsApi.$getInscriptionOrDelegeate as jest.Mock).mockResolvedValue(fakeInscription());
99+
const req = { params: { inscriptionId: `${TXID}i37` } } as unknown as Request;
100+
const res = makeRes();
101+
102+
await (generalOrdpoolRoutes as any).getInscriptionContent(req, res);
103+
104+
expect(ordpoolInscriptionsApi.$getInscriptionOrDelegeate).toHaveBeenCalledWith(`${TXID}i37`);
105+
expect(ordpoolInscriptionsApi.$getFirstImageInscription).not.toHaveBeenCalled();
106+
});
107+
108+
it('returns 404 when first-image lookup yields nothing', async () => {
109+
(ordpoolInscriptionsApi.$getFirstImageInscription as jest.Mock).mockResolvedValue(undefined);
110+
const req = { params: { inscriptionId: TXID } } as unknown as Request;
111+
const res = makeRes();
112+
113+
await (generalOrdpoolRoutes as any).getInscriptionContent(req, res);
114+
115+
expect(res.status).toHaveBeenCalledWith(404);
116+
expect(res.send).toHaveBeenCalledWith('Transaction or inscription not found.');
117+
});
118+
119+
it('returns 404 when inscription-id lookup yields nothing', async () => {
120+
(ordpoolInscriptionsApi.$getInscriptionOrDelegeate as jest.Mock).mockResolvedValue(undefined);
121+
const req = { params: { inscriptionId: `${TXID}i0` } } as unknown as Request;
122+
const res = makeRes();
123+
124+
await (generalOrdpoolRoutes as any).getInscriptionContent(req, res);
125+
126+
expect(res.status).toHaveBeenCalledWith(404);
127+
});
128+
129+
it('returns 400 when no id is supplied at all', async () => {
130+
const req = { params: {} } as unknown as Request;
131+
const res = makeRes();
132+
133+
await (generalOrdpoolRoutes as any).getInscriptionContent(req, res);
134+
135+
expect(res.status).toHaveBeenCalledWith(400);
136+
expect(ordpoolInscriptionsApi.$getFirstImageInscription).not.toHaveBeenCalled();
137+
expect(ordpoolInscriptionsApi.$getInscriptionOrDelegeate).not.toHaveBeenCalled();
138+
});
139+
140+
it('returns 500 when the resolver throws', async () => {
141+
(ordpoolInscriptionsApi.$getFirstImageInscription as jest.Mock).mockRejectedValue(new Error('boom'));
142+
const req = { params: { inscriptionId: TXID } } as unknown as Request;
143+
const res = makeRes();
144+
145+
await (generalOrdpoolRoutes as any).getInscriptionContent(req, res);
146+
147+
expect(res.status).toHaveBeenCalledWith(500);
148+
});
149+
});

backend/src/api/explorer/_ordpool/ordpool.routes.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,10 @@ class GeneralOrdpoolRoutes {
190190
}
191191

192192

193-
function isBareTxid(value: string): boolean {
193+
// Exported for testing. Bare txid = 64 hex chars with no `iN` suffix.
194+
// The /content/ route uses this to decide whether to look up a specific
195+
// inscription index or pick the first image-bearing one in the tx.
196+
export function isBareTxid(value: string): boolean {
194197
return /^[0-9a-fA-F]{64}$/.test(value);
195198
}
196199

0 commit comments

Comments
 (0)