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),
+)
+
+
+
+
+
+ {{ label }}
+
+
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),
+ )
+ })
+})