[READ-ONLY] a fast, modern browser for the npm registry
at main 172 lines 5.4 kB view raw
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})