Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions app/composables/npm/usePackage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeLicense } from '#shared/utils/npm'

/** Number of recent versions to include in initial payload */
const RECENT_VERSIONS_COUNT = 5

Expand All @@ -16,13 +18,6 @@ function getTrustLevel(version: PackumentVersion): PublishTrustLevel {
return 'none'
}

function normalizeLicense(license?: PackumentLicense): string | undefined {
if (!license) return undefined
if (typeof license === 'string') return license
if (typeof license.type === 'string') return license.type
return undefined
}

/**
* Transform a full Packument into a slimmed version for client-side use.
* Reduces payload size by:
Expand Down
19 changes: 13 additions & 6 deletions server/api/registry/license-change/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeLicense } from '#shared/utils/npm'

interface LicenseChangeRecord {
from: string
to: string
Expand Down Expand Up @@ -42,13 +44,18 @@ export default defineCachedEventHandler(
version === 'latest' ? versions.length - 1 : versions.findIndex(v => v.version === version)

const previousVersionIndex = currentVersionIndex - 1
const currentLicense = String(versions[currentVersionIndex]?.license || 'UNKNOWN')
const previousLicense = String(versions[previousVersionIndex]?.license || 'UNKNOWN')

if (currentLicense !== previousLicense) {
change = {
from: previousLicense,
to: currentLicense,
// Skip when there's no real previous version, else we'd diff against a phantom 'UNKNOWN'.
if (currentVersionIndex > 0) {
const currentLicense = normalizeLicense(versions[currentVersionIndex]?.license) ?? 'UNKNOWN'
const previousLicense =
normalizeLicense(versions[previousVersionIndex]?.license) ?? 'UNKNOWN'

if (currentLicense !== previousLicense) {
change = {
from: previousLicense,
to: currentLicense,
}
}
}
return { change }
Expand Down
15 changes: 15 additions & 0 deletions shared/utils/npm.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getLatestVersion } from 'fast-npm-meta'
import { createError } from 'h3'
import validatePackageName from 'validate-npm-package-name'
import type { PackumentLicense } from '#shared/types/npm-registry'

const NPM_USERNAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i
const NPM_USERNAME_MAX_LENGTH = 50
Expand Down Expand Up @@ -62,3 +63,17 @@ export function assertValidUsername(username: string): void {
})
}
}

/**
* Normalize a packument `license` field to a plain string.
* The field can be a string or an object with a `type` property.
*
* @param license Raw license value from a packument
* @returns License string, or `undefined` if not present or unrecognized
*/
export function normalizeLicense(license?: PackumentLicense): string | undefined {
if (!license) return undefined
if (typeof license === 'string') return license
if (typeof license.type === 'string') return license.type
return undefined
}
210 changes: 210 additions & 0 deletions test/unit/server/api/registry/license-change/pkg.get.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { describe, expect, it, vi, beforeEach, afterAll } from 'vitest'
import { createError, type H3Event } from 'h3'
import type { Packument, PackumentVersion } from '#shared/types/npm-registry'

const fetchNpmPackageMock = vi.fn()
vi.stubGlobal('fetchNpmPackage', fetchNpmPackageMock)
vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn)

let routerParam: string | undefined
let queryParams: Record<string, string | number> = {}

vi.stubGlobal('getRouterParam', (_event: unknown, _name: string) => routerParam)
vi.stubGlobal('getQuery', () => queryParams)
vi.stubGlobal('createError', createError)

const handler = (await import('#server/api/registry/license-change/[...pkg].get')).default

function makePackument(opts: {
versions: Record<string, Partial<PackumentVersion>>
time: Record<string, string>
}): Packument {
return {
'dist-tags': {},
'versions': Object.fromEntries(
Object.entries(opts.versions).map(([v, data]) => [v, { version: v, ...data }]),
),
'time': opts.time,
} as Packument
}

const fakeEvent = {} as H3Event

afterAll(() => {
vi.unstubAllGlobals()
})

describe('license-change API', () => {
beforeEach(() => {
vi.clearAllMocks()
routerParam = undefined
queryParams = {}
})

it('throws 400 when package name param is missing', async () => {
routerParam = undefined
await expect(handler(fakeEvent)).rejects.toMatchObject({ statusCode: 400 })
})

it('reports no change for a new package with a single version (issue #2720)', async () => {
routerParam = 'vsxtools'

fetchNpmPackageMock.mockResolvedValue(
makePackument({
versions: { '0.0.1': { license: 'MIT' } },
time: { '0.0.1': '2024-01-01T00:00:00Z' },
}),
)

const result = await handler(fakeEvent)
expect(result.change).toBeNull()
})

it('reports a change when the license changed between versions', async () => {
routerParam = 'my-pkg'

fetchNpmPackageMock.mockResolvedValue(
makePackument({
versions: {
'1.0.0': { license: 'MIT' },
'2.0.0': { license: 'ISC' },
},
time: {
'1.0.0': '2024-01-01T00:00:00Z',
'2.0.0': '2024-06-01T00:00:00Z',
},
}),
)

const result = await handler(fakeEvent)
expect(result.change).toEqual({ from: 'MIT', to: 'ISC' })
})

it('reports no change when the license is unchanged between versions', async () => {
routerParam = 'my-pkg'

fetchNpmPackageMock.mockResolvedValue(
makePackument({
versions: {
'1.0.0': { license: 'MIT' },
'2.0.0': { license: 'MIT' },
},
time: {
'1.0.0': '2024-01-01T00:00:00Z',
'2.0.0': '2024-06-01T00:00:00Z',
},
}),
)

const result = await handler(fakeEvent)
expect(result.change).toBeNull()
})

it('defaults to the latest (chronologically newest) version', async () => {
routerParam = 'my-pkg'
queryParams = {}

fetchNpmPackageMock.mockResolvedValue(
makePackument({
versions: {
'1.0.0': { license: 'MIT' },
'2.0.0': { license: 'MIT' },
'3.0.0': { license: 'Apache-2.0' },
},
time: {
'1.0.0': '2024-01-01T00:00:00Z',
'2.0.0': '2024-06-01T00:00:00Z',
'3.0.0': '2025-01-01T00:00:00Z',
},
}),
)

const result = await handler(fakeEvent)
expect(result.change).toEqual({ from: 'MIT', to: 'Apache-2.0' })
})

it('compares the requested version against its predecessor', async () => {
routerParam = 'my-pkg'
queryParams = { version: '2.0.0' }

fetchNpmPackageMock.mockResolvedValue(
makePackument({
versions: {
'1.0.0': { license: 'MIT' },
'2.0.0': { license: 'ISC' },
'3.0.0': { license: 'Apache-2.0' },
},
time: {
'1.0.0': '2024-01-01T00:00:00Z',
'2.0.0': '2024-06-01T00:00:00Z',
'3.0.0': '2025-01-01T00:00:00Z',
},
}),
)

const result = await handler(fakeEvent)
expect(result.change).toEqual({ from: 'MIT', to: 'ISC' })
})

it('reports no change when the requested version is the oldest', async () => {
routerParam = 'my-pkg'
queryParams = { version: '1.0.0' }

fetchNpmPackageMock.mockResolvedValue(
makePackument({
versions: {
'1.0.0': { license: 'MIT' },
'2.0.0': { license: 'ISC' },
},
time: {
'1.0.0': '2024-01-01T00:00:00Z',
'2.0.0': '2024-06-01T00:00:00Z',
},
}),
)

const result = await handler(fakeEvent)
expect(result.change).toBeNull()
})

it('reports no change when the requested version is not found', async () => {
routerParam = 'my-pkg'
queryParams = { version: '9.9.9' }

fetchNpmPackageMock.mockResolvedValue(
makePackument({
versions: {
'1.0.0': { license: 'MIT' },
'2.0.0': { license: 'ISC' },
},
time: {
'1.0.0': '2024-01-01T00:00:00Z',
'2.0.0': '2024-06-01T00:00:00Z',
},
}),
)

const result = await handler(fakeEvent)
expect(result.change).toBeNull()
})

it('normalizes object-shaped licenses by extracting the type field', async () => {
routerParam = 'my-pkg'

fetchNpmPackageMock.mockResolvedValue(
makePackument({
versions: {
'1.0.0': { license: { type: 'MIT' } as never },
'2.0.0': { license: { type: 'Apache-2.0', url: 'https://example.com' } as never },
},
time: {
'1.0.0': '2024-01-01T00:00:00Z',
'2.0.0': '2024-06-01T00:00:00Z',
},
}),
)

const result = await handler(fakeEvent)
expect(result.change).toEqual({ from: 'MIT', to: 'Apache-2.0' })
})
})
Comment on lines +37 to +210
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add test coverage for license objects.

The test suite thoroughly validates the guard logic for single-version, multi-version, and version-not-found cases. However, it doesn't cover the scenario where a license is an object (e.g., { type: 'MIT' }) rather than a string. According to shared/types/npm-registry.ts, PackumentLicense can be string | { type: string; url?: string }.

Adding a test case for object licenses would ensure the handler normalizes them correctly (extracting the type field) and prevent regressions.

📋 Suggested test case for license objects
it('handles license objects by extracting the type field', async () => {
  routerParam = 'my-pkg'

  fetchNpmPackageMock.mockResolvedValue(
    makePackument({
      versions: {
        '1.0.0': { license: { type: 'MIT' } },
        '2.0.0': { license: { type: 'Apache-2.0', url: 'https://...' } },
      },
      time: {
        '1.0.0': '2024-01-01T00:00:00Z',
        '2.0.0': '2024-06-01T00:00:00Z',
      },
    }),
  )

  const result = await handler(fakeEvent)
  expect(result.change).toEqual({ from: 'MIT', to: 'Apache-2.0' })
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/server/api/registry/license-change/pkg.get.spec.ts` around lines 37
- 190, Add a new test in this spec to exercise object-shaped licenses: create a
case (similar to the suggested snippet) that sets routerParam = 'my-pkg', mocks
fetchNpmPackageMock via makePackument to return versions where license values
are objects (e.g. { type: 'MIT' } and { type: 'Apache-2.0', url: '...' }), call
handler(fakeEvent), and assert the returned change equals { from: 'MIT', to:
'Apache-2.0' }; this ensures the handler's license normalization logic (used
when reading packument versions) correctly extracts the type field from object
licenses.

Loading