@@ -118,6 +118,21 @@ describe('fetchPackagistStats', () => {
118118 expect ( await fetchPackagistStats ( 'monolog/monolog' ) ) . toMatchObject ( { kind : 'MALFORMED' } )
119119 } )
120120
121+ // Regression: a technically-valid-JSON body with the wrong runtime type for a field
122+ // normalizePackagistStats consumes unconditionally must be classified MALFORMED, not
123+ // thrown past the guard (where it would be misread as a transient failure and
124+ // retried forever on the same deterministic input).
125+ it . each ( [
126+ [ 'description is a number' , { package : { name : 'a/b' , description : 123 } } ] ,
127+ [ 'repository is an object' , { package : { name : 'a/b' , repository : { } } } ] ,
128+ [ 'maintainers is not an array' , { package : { name : 'a/b' , maintainers : { } } } ] ,
129+ ] ) ( 'maps a wrong-typed field (%s) → MALFORMED, not a throw' , async ( _desc , body ) => {
130+ vi . stubGlobal ( 'fetch' , vi . fn ( ) . mockResolvedValue ( fakeResponse ( 200 , body ) ) )
131+ await expect ( fetchPackagistStats ( 'monolog/monolog' ) ) . resolves . toMatchObject ( {
132+ kind : 'MALFORMED' ,
133+ } )
134+ } )
135+
121136 it ( 'maps a body read aborted by the 30s timeout → TRANSIENT (not MALFORMED)' , async ( ) => {
122137 vi . useFakeTimers ( )
123138 vi . stubGlobal (
@@ -198,4 +213,39 @@ describe('fetchPackagistP2', () => {
198213 )
199214 expect ( await fetchPackagistP2 ( 'monolog/monolog' , null ) ) . toMatchObject ( { kind : 'MALFORMED' } )
200215 } )
216+
217+ // Regression: a malformed element in the version array must be classified
218+ // MALFORMED, not thrown past the guard — expandComposerMetadata's Object.entries()
219+ // throws on a null/non-object element, and version/version_normalized/license reach
220+ // .startsWith()/.split()/.endsWith() and the SQL text[] write unconditionally.
221+ it . each ( [
222+ [ 'an element is null' , [ null ] ] ,
223+ [ 'version is missing' , [ { time : '2024-01-01' } ] ] ,
224+ [ 'version is a number' , [ { version : 123 } ] ] ,
225+ [ 'version_normalized is a number' , [ { version : '1.0.0' , version_normalized : 42 } ] ] ,
226+ [ 'license is a scalar string, not an array' , [ { version : '1.0.0' , license : 'MIT' } ] ] ,
227+ [ 'homepage is an object' , [ { version : '1.0.0' , homepage : { } } ] ] ,
228+ [ 'time is a number' , [ { version : '1.0.0' , time : 20240101 } ] ] ,
229+ ] ) ( 'maps a malformed version entry (%s) → MALFORMED, not a throw' , async ( _desc , versions ) => {
230+ vi . stubGlobal (
231+ 'fetch' ,
232+ vi . fn ( ) . mockResolvedValue ( fakeResponse ( 200 , { packages : { 'monolog/monolog' : versions } } ) ) ,
233+ )
234+ await expect ( fetchPackagistP2 ( 'monolog/monolog' , null ) ) . resolves . toMatchObject ( {
235+ kind : 'MALFORMED' ,
236+ } )
237+ } )
238+
239+ it ( 'accepts a version entry using the __unset diff sentinel on optional fields' , async ( ) => {
240+ const versions = [
241+ { version : '1.0.0' , license : [ 'MIT' ] } ,
242+ { version : '2.0.0' , license : '__unset' } ,
243+ ]
244+ vi . stubGlobal (
245+ 'fetch' ,
246+ vi . fn ( ) . mockResolvedValue ( fakeResponse ( 200 , { packages : { 'monolog/monolog' : versions } } ) ) ,
247+ )
248+ const result = await fetchPackagistP2 ( 'monolog/monolog' , null )
249+ expect ( result ) . not . toMatchObject ( { kind : 'MALFORMED' } )
250+ } )
201251} )
0 commit comments