forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { describe, expect, it, vi, beforeEach, type Mocked } from 'vitest'
2import { TID } from '@atproto/common'
3import type { ConstellationLike } from '../../../../server/utils/atproto/utils/likes'
4import type { CacheAdapter } from '../../../../server/utils/cache/shared'
5
6vi.stubGlobal('CACHE_MAX_AGE_ONE_MINUTE', 60)
7vi.stubGlobal('PACKAGE_SUBJECT_REF', (pkg: string) => `https://npmx.dev/package/${pkg}`)
8vi.stubGlobal('$fetch', vi.fn())
9vi.stubGlobal('Constellation', vi.fn())
10vi.stubGlobal('getCacheAdapter', vi.fn())
11
12vi.mock('#shared/types/lexicons/dev/npmx/feed/like.defs', () => ({
13 $nsid: 'dev.npmx.feed.like',
14}))
15
16const { aggregateBacklinksByDay, PackageLikesUtils } =
17 await import('../../../../server/utils/atproto/utils/likes')
18
19function tidFromDate(date: Date): string {
20 const microseconds = date.getTime() * 1000
21 return TID.fromTime(microseconds, 0).toString()
22}
23
24function backlink(date: Date): { did: string; collection: string; rkey: string } {
25 return { did: 'did:plc:test', collection: 'dev.npmx.feed.like', rkey: tidFromDate(date) }
26}
27
28describe('aggregateBacklinksByDay', () => {
29 it('groups backlinks by day from TID rkeys', () => {
30 const result = aggregateBacklinksByDay([
31 backlink(new Date('2025-03-10T12:00:00.000Z')),
32 backlink(new Date('2025-03-10T18:00:00.000Z')),
33 backlink(new Date('2025-03-11T09:00:00.000Z')),
34 ])
35
36 expect(result).toEqual([
37 { day: '2025-03-10', likes: 2 },
38 { day: '2025-03-11', likes: 1 },
39 ])
40 })
41
42 it('sorts results chronologically', () => {
43 const result = aggregateBacklinksByDay([
44 backlink(new Date('2025-05-03T10:00:00.000Z')),
45 backlink(new Date('2025-05-01T10:00:00.000Z')),
46 backlink(new Date('2025-05-02T10:00:00.000Z')),
47 ])
48
49 expect(result.map(r => r.day)).toEqual(['2025-05-01', '2025-05-02', '2025-05-03'])
50 })
51
52 it('skips non-TID rkeys with warning', () => {
53 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
54
55 const result = aggregateBacklinksByDay([
56 { did: 'did:plc:user1', collection: 'dev.npmx.feed.like', rkey: 'not-a-valid-tid' },
57 backlink(new Date('2025-04-20T10:00:00.000Z')),
58 ])
59
60 expect(result).toEqual([{ day: '2025-04-20', likes: 1 }])
61 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('non-TID rkey'))
62
63 warnSpy.mockRestore()
64 })
65
66 it('returns empty array for empty input', () => {
67 expect(aggregateBacklinksByDay([])).toEqual([])
68 })
69
70 it('aggregates multiple likes on same day', () => {
71 const result = aggregateBacklinksByDay([
72 backlink(new Date('2025-07-04T08:00:00.000Z')),
73 backlink(new Date('2025-07-04T12:00:00.000Z')),
74 backlink(new Date('2025-07-04T20:00:00.000Z')),
75 ])
76
77 expect(result).toEqual([{ day: '2025-07-04', likes: 3 }])
78 })
79})
80
81function makeBacklinksPage(
82 records: Array<{ did: string; collection: string; rkey: string }>,
83 cursor?: string,
84) {
85 return {
86 data: {
87 records,
88 total: records.length,
89 cursor,
90 },
91 isStale: false,
92 cachedAt: null,
93 }
94}
95
96describe('PackageLikesUtils.getLikesEvolution', () => {
97 const mockConstellation: Mocked<ConstellationLike> = {
98 getBackLinks: vi.fn(),
99 getLinksDistinctDids: vi.fn(),
100 }
101 // vi.fn() can't represent CacheAdapter's generic get<T>/set<T> signatures,
102 // so we assert once here and get full type-safety everywhere else.
103 const mockCache = {
104 get: vi.fn(),
105 set: vi.fn(),
106 delete: vi.fn(),
107 } as unknown as Mocked<CacheAdapter>
108
109 beforeEach(() => {
110 vi.clearAllMocks()
111 mockCache.get.mockResolvedValue(undefined)
112 mockCache.set.mockResolvedValue(undefined)
113 })
114
115 it('paginates through backlinks and caches the result', async () => {
116 const day1 = new Date('2025-01-15T10:00:00.000Z')
117 const day2 = new Date('2025-01-16T10:00:00.000Z')
118
119 // Page 1 returns a cursor
120 mockConstellation.getBackLinks.mockResolvedValueOnce(
121 makeBacklinksPage([backlink(day1)], 'cursor-page-2'),
122 )
123 // Page 2 returns no cursor (end)
124 mockConstellation.getBackLinks.mockResolvedValueOnce(makeBacklinksPage([backlink(day2)]))
125
126 const utils = new PackageLikesUtils({
127 constellation: mockConstellation,
128 cache: mockCache,
129 })
130
131 const result = await utils.getLikesEvolution('react')
132
133 expect(mockConstellation.getBackLinks).toHaveBeenCalledTimes(2)
134 expect(result).toEqual([
135 { day: '2025-01-15', likes: 1 },
136 { day: '2025-01-16', likes: 1 },
137 ])
138 expect(mockCache.set).toHaveBeenCalledWith(
139 expect.stringContaining('evolution'),
140 result,
141 expect.any(Number),
142 )
143 })
144
145 it('returns cached result without calling constellation', async () => {
146 const cachedData = [{ day: '2025-06-01', likes: 5 }]
147 mockCache.get.mockResolvedValueOnce(cachedData)
148
149 const utils = new PackageLikesUtils({
150 constellation: mockConstellation,
151 cache: mockCache,
152 })
153
154 const result = await utils.getLikesEvolution('lodash')
155
156 expect(mockConstellation.getBackLinks).not.toHaveBeenCalled()
157 expect(result).toEqual(cachedData)
158 })
159
160 it('returns empty array when no backlinks exist', async () => {
161 mockConstellation.getBackLinks.mockResolvedValueOnce(makeBacklinksPage([]))
162
163 const utils = new PackageLikesUtils({
164 constellation: mockConstellation,
165 cache: mockCache,
166 })
167
168 const result = await utils.getLikesEvolution('empty-pkg')
169
170 expect(result).toEqual([])
171 })
172})