diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 256c5d6f4f..5980242601 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -250,6 +250,7 @@ useShortcuts({
+ +import type { PackageSocialMentions } from '#shared/types/social' +import { packageSubjectRef, SOCIAL_MENTIONS_MAX } from '#shared/utils/constants' + +const props = defineProps<{ + packageName: string +}>() + +const compactNumberFormatter = useCompactNumberFormatter() + +const { + data: mentionsData, + status, + execute: fetchMentions, +} = useLazyFetch(() => `/api/social/mentions/${props.packageName}`, { + default: () => ({ totalMentions: 0, isCapped: false }), + server: false, + immediate: false, +}) + +// Client-only: this decorative stat should never block SSR. +if (import.meta.client) { + watch( + () => props.packageName, + packageName => { + if (packageName) fetchMentions() + }, + { immediate: true }, + ) +} + +const totalMentions = computed(() => mentionsData.value?.totalMentions ?? 0) +const isCapped = computed(() => mentionsData.value?.isCapped ?? false) +const displayCount = computed(() => + isCapped.value + ? `${SOCIAL_MENTIONS_MAX}+` + : compactNumberFormatter.value.format(totalMentions.value), +) +const isLoading = computed(() => status.value === 'pending' || status.value === 'idle') +// Search the exact npmx.dev URL to avoid noisy matches on package names like "react". +const searchUrl = computed( + () => `https://bsky.app/search?q=${encodeURIComponent(packageSubjectRef(props.packageName))}`, +) +const label = computed(() => + $t('package.social_mentions.label', { count: displayCount.value }, totalMentions.value), +) + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index acb9f3e050..bb74812978 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -555,6 +555,9 @@ "top_rank_label": "#{rank}", "top_rank_link_label": "View likes leaderboard. This package is ranked #{rank}." }, + "social_mentions": { + "label": "{count} mention | {count} mentions" + }, "docs": { "contents": "Contents", "default_not_available": "Docs are not available for this version.", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 909d3f375a..326209736b 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -453,6 +453,9 @@ "top_rank_label": "n°{rank}", "top_rank_link_label": "Voir le classement des likes. Ce paquet est classé n°{rank}." }, + "social_mentions": { + "label": "{count} mention | {count} mention | {count} mentions" + }, "docs": { "contents": "Sommaire", "default_not_available": "La documentation n'est pas disponible pour cette version.", diff --git a/i18n/schema.json b/i18n/schema.json index fcc89367f2..6ab603bc63 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1669,6 +1669,15 @@ }, "additionalProperties": false }, + "social_mentions": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "additionalProperties": false + }, "docs": { "type": "object", "properties": { diff --git a/server/api/social/like.post.ts b/server/api/social/like.post.ts index 3bdcf54105..68dbca78a4 100644 --- a/server/api/social/like.post.ts +++ b/server/api/social/like.post.ts @@ -26,7 +26,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => { return likesResult } - const subjectRef = PACKAGE_SUBJECT_REF(body.packageName) + const subjectRef = packageSubjectRef(body.packageName) const client = new Client(oAuthSession) const like = dev.npmx.feed.like.$build({ diff --git a/server/api/social/likes/[...pkg].get.ts b/server/api/social/likes/[...pkg].get.ts index 8db4c25e49..f084b6ed65 100644 --- a/server/api/social/likes/[...pkg].get.ts +++ b/server/api/social/likes/[...pkg].get.ts @@ -1,6 +1,6 @@ import * as v from 'valibot' import { PackageRouteParamsSchema } from '#shared/schemas/package' -import { PACKAGE_SUBJECT_REF } from '#shared/utils/constants' +import { packageSubjectRef } from '#shared/utils/constants' import { getTopLikedRank } from '#server/utils/likes-leaderboard' /** @@ -27,7 +27,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => { const likesUtil = new PackageLikesUtils() const [likes, topLikedRank] = await Promise.all([ likesUtil.getLikes(packageName, oAuthSession?.did.toString()), - getTopLikedRank(event, PACKAGE_SUBJECT_REF(packageName)), + getTopLikedRank(event, packageSubjectRef(packageName)), ]) return { diff --git a/server/api/social/mentions/[...pkg].get.ts b/server/api/social/mentions/[...pkg].get.ts new file mode 100644 index 0000000000..12ae2d88c5 --- /dev/null +++ b/server/api/social/mentions/[...pkg].get.ts @@ -0,0 +1,50 @@ +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' + +/** + * GET /api/social/mentions/:name + * + * Gets the number of unique social posts that mention a npm package, capped at 99+ + */ +export default defineEventHandler(async event => { + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + const { rawPackageName } = parsePackageParams(pkgParamSegments) + + if (!rawPackageName) { + throw createError({ + status: 400, + message: 'package name not provided', + }) + } + + let decodedPackageName: string + try { + decodedPackageName = decodeURIComponent(rawPackageName) + } catch { + throw createError({ + status: 400, + message: 'Invalid package name encoding', + }) + } + + try { + const { packageName } = v.parse(PackageRouteParamsSchema, { + packageName: decodedPackageName, + }) + + const mentionsUtil = new PackageSocialMentionsUtils() + const mentions = await mentionsUtil.getMentions(packageName) + + setHeader( + event, + 'Cache-Control', + `public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`, + ) + return mentions + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to get social mentions', + }) + } +}) diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts index 54101e2b8e..18c280a661 100644 --- a/server/utils/atproto/utils/likes.ts +++ b/server/utils/atproto/utils/likes.ts @@ -115,7 +115,7 @@ export class PackageLikesUtils { async getLikes(packageName: string, usersDid?: string | undefined): Promise { //TODO: May need to do some clean up on the package name, and maybe even hash it? some of the characters may be a bit odd as keys const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const cachedLikes = await this.cache.get(totalLikesKey) let totalLikes = 0 @@ -161,7 +161,7 @@ export class PackageLikesUtils { if (cached !== undefined) { return cached } - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid) await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE) @@ -181,7 +181,7 @@ export class PackageLikesUtils { atUri: string, ): Promise { const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const splitAtUri = atUri.replace('at://', '').split('/') const collection = splitAtUri[1] @@ -231,7 +231,7 @@ export class PackageLikesUtils { return backLink } - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const { data: userLikes } = await this.constellation.getBackLinks( subjectRef, likeNsid, @@ -265,7 +265,7 @@ export class PackageLikesUtils { */ async unlikeAPackageAndReturnLikes(packageName: string, usersDid: string): Promise { const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) let totalLikes = await this.cache.get(totalLikesKey) if (!totalLikes) { @@ -319,7 +319,7 @@ export class PackageLikesUtils { const cached = await this.cache.get>(cacheKey) if (cached) return cached - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const allBacklinks: Backlink[] = [] let cursor: string | undefined diff --git a/server/utils/atproto/utils/social-mentions.ts b/server/utils/atproto/utils/social-mentions.ts new file mode 100644 index 0000000000..70232d5f6f --- /dev/null +++ b/server/utils/atproto/utils/social-mentions.ts @@ -0,0 +1,97 @@ +import type { Backlink } from '#shared/utils/constellation' +import { + CACHE_MAX_AGE_ONE_HOUR, + packageSubjectRef, + SOCIAL_MENTIONS_MAX, +} from '#shared/utils/constants' + +const CACHE_PREFIX = 'atproto-social-mentions:' +const cachePackageTotalKey = (packageName: string) => `${CACHE_PREFIX}${packageName}:total` + +const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_HOUR + +const BLUESKY_POST_COLLECTION = 'app.bsky.feed.post' +// getBackLinks expects paths without the leading dot that /links/all returns. +const BLUESKY_LINK_PATHS = [ + 'embed.external.uri', + 'facets[].features[app.bsky.richtext.facet#link].uri', +] as const + +/** The subset of Constellation that PackageSocialMentionsUtils actually needs. */ +export type ConstellationMentions = Pick + +/** + * Counts unique Bluesky posts that link to an npmx.dev package. + * + * Posts that both embed and facet-link the URL are only counted once. At most + * one page of 100 records is read per source path; counts above 99 are capped. + */ +export class PackageSocialMentionsUtils { + private readonly constellation: ConstellationMentions + private readonly cache: CacheAdapter + + constructor(deps?: { constellation?: ConstellationMentions; cache?: CacheAdapter }) { + this.constellation = + deps?.constellation ?? + new Constellation( + // Passes in a fetch wrapped as CachedFetch because we're already caching here + async ( + url: string, + options: Parameters[1] = {}, + _ttl?: number, + ): Promise> => { + const data = (await $fetch(url, options)) as T + return { data, isStale: false, cachedAt: null } + }, + ) + this.cache = deps?.cache ?? getCacheAdapter('generic') + } + + private async constellationPostKeys(target: string, recordPath: string) { + const { data } = await this.constellation.getBackLinks( + target, + BLUESKY_POST_COLLECTION, + recordPath, + 100, + undefined, + false, + [], + 0, + ) + + return { + postKeys: data.records.map(postKey), + hasMore: Boolean(data.cursor), + } + } + + async getMentions(packageName: string): Promise { + const cacheKey = cachePackageTotalKey(packageName) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const target = packageSubjectRef(packageName) + const results = await Promise.all( + BLUESKY_LINK_PATHS.map(recordPath => this.constellationPostKeys(target, recordPath)), + ) + + const uniquePosts = new Set() + let hasMore = false + for (const result of results) { + hasMore ||= result.hasMore + for (const key of result.postKeys) uniquePosts.add(key) + } + + const isCapped = hasMore || uniquePosts.size > SOCIAL_MENTIONS_MAX + const result: PackageSocialMentions = { + totalMentions: isCapped ? SOCIAL_MENTIONS_MAX : uniquePosts.size, + isCapped, + } + await this.cache.set(cacheKey, result, CACHE_MAX_AGE) + return result + } +} + +function postKey(record: Backlink): string { + return `${record.did}:${record.collection}:${record.rkey}` +} diff --git a/shared/types/social.ts b/shared/types/social.ts index b74e6595e8..76aa3d0040 100644 --- a/shared/types/social.ts +++ b/shared/types/social.ts @@ -10,6 +10,16 @@ export type PackageLikes = { topLikedRank: number | null } +/** + * Approximate number of social posts mentioning a npm package + */ +export type PackageSocialMentions = { + // Exact unique mention count up to 99, or 99 when capped + totalMentions: number + // True when there are more than 99 unique mentions + isCapped: boolean +} + export type LikesLeaderboardEntry = { rank: number packageName: string diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 402eb41606..f979a6d074 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -58,10 +58,11 @@ export const ERROR_UNKNOWN_GIT_HOST = export const CONSTELLATION_HOST = 'constellation.microcosm.blue' export const SLINGSHOT_HOST = 'slingshot.microcosm.blue' +export const SOCIAL_MENTIONS_MAX = 99 + // ATProtocol // References used to link packages to things that are not inherently atproto -export const PACKAGE_SUBJECT_REF = (packageName: string) => - `https://npmx.dev/package/${packageName}` +export const packageSubjectRef = (packageName: string) => `https://npmx.dev/package/${packageName}` // OAuth scopes as we add new ones we need to check these on certain actions. If not redirect the user to login again to upgrade the scopes export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}` export const PROFILE_SCOPE = `repo:${dev.npmx.actor.profile.$nsid}` diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index f17d325cd4..09a742a4c2 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -289,6 +289,7 @@ import { PackagePlaygrounds, PackageReplacement, PackageSidebar, + PackageSocialMentions, PackageSkeleton, PackageSkillsCard, PackageTable, @@ -948,6 +949,26 @@ describe('component accessibility audits', () => { }) }) + describe('PackageSocialMentions', () => { + it('should have no accessibility violations', async () => { + registerEndpoint('/api/social/mentions/vue', () => ({ totalMentions: 12 })) + + const component = await mountSuspended(PackageSocialMentions, { + props: { packageName: 'vue' }, + }) + + await vi.waitFor( + () => { + expect(component.text()).toContain('12 mentions') + }, + { timeout: 3000 }, + ) + + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('LikesLeaderboardPage', () => { it('should have no accessibility violations when data is available', async () => { clearNuxtData() diff --git a/test/nuxt/components/Package/SocialMentions.spec.ts b/test/nuxt/components/Package/SocialMentions.spec.ts new file mode 100644 index 0000000000..18c5988528 --- /dev/null +++ b/test/nuxt/components/Package/SocialMentions.spec.ts @@ -0,0 +1,92 @@ +import type { VueWrapper } from '@vue/test-utils' +import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import SocialMentions from '~/components/Package/SocialMentions.vue' + +describe('PackageSocialMentions', () => { + let wrapper: VueWrapper | undefined + + beforeEach(() => { + clearNuxtData() + }) + + afterEach(() => { + wrapper?.unmount() + wrapper = undefined + }) + + it('shows a spinner instead of 0 while loading', async () => { + let resolveMentions!: (value: { totalMentions: number }) => void + registerEndpoint( + '/api/social/mentions/loading-pkg', + () => + new Promise<{ totalMentions: number }>(resolve => { + resolveMentions = resolve + }), + ) + + wrapper = await mountSuspended(SocialMentions, { + props: { packageName: 'loading-pkg' }, + attachTo: document.body, + }) + + const link = wrapper.get('[data-testid="social-mentions"]') + expect(link.find('[class*="ring-resize"]').exists()).toBe(true) + expect(wrapper.text()).not.toContain('0 mentions') + expect(link.attributes('aria-label')).toBe('Loading...') + + resolveMentions({ totalMentions: 5 }) + + await vi.waitFor(() => { + expect(wrapper?.text()).toContain('5 mentions') + }) + }) + + it('shows 0 mentions after loading resolves with zero', async () => { + registerEndpoint('/api/social/mentions/zero-pkg', () => ({ totalMentions: 0 })) + + wrapper = await mountSuspended(SocialMentions, { + props: { packageName: 'zero-pkg' }, + attachTo: document.body, + }) + + await vi.waitFor(() => { + expect(wrapper?.text()).toContain('0 mentions') + }) + }) + + it('renders 99+ for capped counts', async () => { + registerEndpoint('/api/social/mentions/popular-pkg', () => ({ + totalMentions: 99, + isCapped: true, + })) + + wrapper = await mountSuspended(SocialMentions, { + props: { packageName: 'popular-pkg' }, + attachTo: document.body, + }) + + await vi.waitFor(() => { + expect(wrapper?.text()).toContain('99+ mentions') + }) + }) + + it('links to the exact npmx.dev package URL search', async () => { + registerEndpoint('/api/social/mentions/vue', () => ({ totalMentions: 12 })) + + wrapper = await mountSuspended(SocialMentions, { + props: { packageName: 'vue' }, + attachTo: document.body, + }) + + await vi.waitFor(() => { + expect(wrapper?.text()).toContain('12 mentions') + }) + + const link = wrapper.get('[data-testid="social-mentions"]') + expect(link.attributes('href')).toBe( + `https://bsky.app/search?q=${encodeURIComponent('https://npmx.dev/package/vue')}`, + ) + expect(link.attributes('aria-label')).toBe('12 mentions') + }) +}) diff --git a/test/unit/server/utils/likes-evolution.spec.ts b/test/unit/server/utils/likes-evolution.spec.ts index 7221ac48b5..dd360ac406 100644 --- a/test/unit/server/utils/likes-evolution.spec.ts +++ b/test/unit/server/utils/likes-evolution.spec.ts @@ -4,7 +4,7 @@ import type { ConstellationLike } from '#server/utils/atproto/utils/likes' import type { CacheAdapter } from '#server/utils/cache/shared' vi.stubGlobal('CACHE_MAX_AGE_ONE_MINUTE', 60) -vi.stubGlobal('PACKAGE_SUBJECT_REF', (pkg: string) => `https://npmx.dev/package/${pkg}`) +vi.stubGlobal('packageSubjectRef', (pkg: string) => `https://npmx.dev/package/${pkg}`) vi.stubGlobal('$fetch', vi.fn()) vi.stubGlobal('Constellation', vi.fn()) vi.stubGlobal('getCacheAdapter', vi.fn()) diff --git a/test/unit/server/utils/social-mentions.spec.ts b/test/unit/server/utils/social-mentions.spec.ts new file mode 100644 index 0000000000..510b770b05 --- /dev/null +++ b/test/unit/server/utils/social-mentions.spec.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi, type Mocked } from 'vite-plus/test' +import type { ConstellationMentions } from '#server/utils/atproto/utils/social-mentions' +import type { CacheAdapter } from '#server/utils/cache/shared' + +vi.stubGlobal('$fetch', vi.fn()) +vi.stubGlobal('Constellation', vi.fn()) +vi.stubGlobal('getCacheAdapter', vi.fn()) + +const { PackageSocialMentionsUtils } = await import('#server/utils/atproto/utils/social-mentions') + +type TestBacklink = { + did: string + collection: string + rkey: string +} + +function post(did: string, rkey: string): TestBacklink { + return { did, collection: 'app.bsky.feed.post', rkey } +} + +function makeBacklinksPage(records: TestBacklink[], cursor?: string) { + return { + data: { + records, + total: records.length, + cursor, + }, + isStale: false, + cachedAt: null, + } +} + +describe('PackageSocialMentionsUtils.getMentions', () => { + const mockConstellation: Mocked = { + getBackLinks: vi.fn(), + } + // vi.fn() can't represent CacheAdapter's generic get/set signatures, + // so we assert once here and get full type-safety everywhere else. + const mockCache = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + } as unknown as Mocked + + beforeEach(() => { + vi.clearAllMocks() + mockCache.get.mockResolvedValue(undefined) + mockCache.set.mockResolvedValue(undefined) + mockConstellation.getBackLinks.mockResolvedValue(makeBacklinksPage([])) + }) + + it('deduplicates posts across embed and facet links', async () => { + mockConstellation.getBackLinks + .mockResolvedValueOnce( + makeBacklinksPage([post('did:plc:a', 'post-a'), post('did:plc:b', 'post-b')]), + ) + .mockResolvedValueOnce( + makeBacklinksPage([post('did:plc:b', 'post-b'), post('did:plc:c', 'post-c')]), + ) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('zod') + + expect(result).toEqual({ totalMentions: 3, isCapped: false }) + expect(mockConstellation.getBackLinks).toHaveBeenCalledTimes(2) + expect(mockConstellation.getBackLinks).toHaveBeenCalledWith( + 'https://npmx.dev/package/zod', + 'app.bsky.feed.post', + 'embed.external.uri', + 100, + undefined, + false, + [], + 0, + ) + expect(mockConstellation.getBackLinks).toHaveBeenCalledWith( + 'https://npmx.dev/package/zod', + 'app.bsky.feed.post', + 'facets[].features[app.bsky.richtext.facet#link].uri', + 100, + undefined, + false, + [], + 0, + ) + expect(mockCache.set).toHaveBeenCalledWith( + expect.stringContaining('atproto-social-mentions:zod:total'), + { totalMentions: 3, isCapped: false }, + expect.any(Number), + ) + }) + + it('caps at 99 when a source has another page', async () => { + mockConstellation.getBackLinks + .mockResolvedValueOnce(makeBacklinksPage([post('did:plc:a', 'post-a')], 'next')) + .mockResolvedValueOnce(makeBacklinksPage([post('did:plc:b', 'post-b')])) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('zod') + + expect(result).toEqual({ totalMentions: 99, isCapped: true }) + expect(mockConstellation.getBackLinks).toHaveBeenCalledTimes(2) + expect(mockCache.set).toHaveBeenCalledWith( + expect.stringContaining('atproto-social-mentions:zod:total'), + { totalMentions: 99, isCapped: true }, + expect.any(Number), + ) + }) + + it('caps at 99 when unique posts exceed 99', async () => { + mockConstellation.getBackLinks.mockResolvedValueOnce( + makeBacklinksPage(Array.from({ length: 100 }, (_, i) => post('did:plc:a', `post-${i}`))), + ) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('zod') + + expect(result).toEqual({ totalMentions: 99, isCapped: true }) + }) + + it('does not cap exactly 99 unique posts', async () => { + mockConstellation.getBackLinks.mockResolvedValueOnce( + makeBacklinksPage(Array.from({ length: 99 }, (_, i) => post('did:plc:a', `post-${i}`))), + ) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('zod') + + expect(result).toEqual({ totalMentions: 99, isCapped: false }) + }) + + it('returns cached mentions without calling constellation', async () => { + mockCache.get.mockResolvedValueOnce({ totalMentions: 42, isCapped: false }) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('react') + + expect(mockConstellation.getBackLinks).not.toHaveBeenCalled() + expect(result).toEqual({ totalMentions: 42, isCapped: false }) + }) + + it('caches a zero result', async () => { + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('unmentioned-pkg') + + expect(result).toEqual({ totalMentions: 0, isCapped: false }) + expect(mockCache.set).toHaveBeenCalledWith( + expect.stringContaining('unmentioned-pkg:total'), + { totalMentions: 0, isCapped: false }, + expect.any(Number), + ) + }) +})