1+ import { InscriptionParserService } from 'ordpool-parser' ;
2+
13import bitcoinApi from '../../bitcoin/bitcoin-api-factory' ;
24import memPool from '../../mempool' ;
35import 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+ } ) ;
0 commit comments