|
| 1 | +import { describe, expect, it } from 'vitest' |
| 2 | + |
| 3 | +import { |
| 4 | + PYPI_EARLIEST, |
| 5 | + computeLast30dWindows, |
| 6 | + defaultDailyRange, |
| 7 | + utcFirstOfCurrentMonth, |
| 8 | +} from '../pypiDownloadsDates' |
| 9 | +import { |
| 10 | + PYPI_DOWNLOADS_30D_KIND, |
| 11 | + PYPI_DOWNLOADS_DAILY_KIND, |
| 12 | + buildPypiDownloads30dMergeSql, |
| 13 | + buildPypiDownloads30dSql, |
| 14 | + buildPypiDownloadsDailyMergeSql, |
| 15 | + buildPypiDownloadsDailySql, |
| 16 | +} from '../pypiDownloadsSql' |
| 17 | + |
| 18 | +// Note: PEP 503 name normalization is done in SQL (BQ `REGEXP_REPLACE(LOWER(file.project), …)` |
| 19 | +// and the PG merge's `REGEXP_REPLACE(LOWER(p.name), …, 'g')`), asserted in the builder tests |
| 20 | +// below — there is no TS-side normalizer (we don't push our package list into BigQuery). |
| 21 | + |
| 22 | +// ── Criterion 2: monthly 30-day windows (npm-identical math) ─────────────────────────────── |
| 23 | +describe('computeLast30dWindows', () => { |
| 24 | + it('PYPI_EARLIEST is the 2018-07-26 Linehaul floor', () => { |
| 25 | + expect(PYPI_EARLIEST).toBe('2018-07-26') |
| 26 | + }) |
| 27 | + |
| 28 | + it('with no fromDate returns only the latest bucket ending at upperEndDate', () => { |
| 29 | + const w = computeLast30dWindows(null, '2026-06-01') |
| 30 | + expect(w).toEqual([{ start: '2026-05-02', end: '2026-06-01', isLatest: true }]) |
| 31 | + }) |
| 32 | + |
| 33 | + it('enumerates a contiguous monthly series up to and including the latest', () => { |
| 34 | + const w = computeLast30dWindows('2026-04-10', '2026-06-01') |
| 35 | + expect(w.map((x) => x.end)).toEqual(['2026-04-01', '2026-05-01', '2026-06-01']) |
| 36 | + // end_date - 30 days for each bucket |
| 37 | + expect(w.map((x) => x.start)).toEqual(['2026-03-02', '2026-04-01', '2026-05-02']) |
| 38 | + // exactly one latest, and it is the final bucket |
| 39 | + expect(w.filter((x) => x.isLatest)).toEqual([ |
| 40 | + { start: '2026-05-02', end: '2026-06-01', isLatest: true }, |
| 41 | + ]) |
| 42 | + }) |
| 43 | + |
| 44 | + it('clamps the lower bound and start_date to PYPI_EARLIEST', () => { |
| 45 | + const w = computeLast30dWindows('2015-01-01', '2018-09-01') |
| 46 | + // 2018-07-01 bucket is skipped (its end precedes PYPI_EARLIEST) |
| 47 | + expect(w.map((x) => x.end)).toEqual(['2018-08-01', '2018-09-01']) |
| 48 | + // first bucket's start (would be 2018-07-02) is clamped up to the floor |
| 49 | + expect(w[0]).toEqual({ start: '2018-07-26', end: '2018-08-01', isLatest: false }) |
| 50 | + expect(w[1].isLatest).toBe(true) |
| 51 | + }) |
| 52 | + |
| 53 | + it('returns an empty array when fromDate is after upperEndDate', () => { |
| 54 | + expect(computeLast30dWindows('2026-07-01', '2026-06-01')).toEqual([]) |
| 55 | + }) |
| 56 | +}) |
| 57 | + |
| 58 | +// ── Criterion 3: daily trailing window + first-of-month helper ───────────────────────────── |
| 59 | +describe('defaultDailyRange', () => { |
| 60 | + it('defaults to a 2-day self-healing window [today-2, today-1]', () => { |
| 61 | + expect(defaultDailyRange('2026-06-30')).toEqual({ |
| 62 | + startDate: '2026-06-28', |
| 63 | + endDate: '2026-06-29', |
| 64 | + }) |
| 65 | + }) |
| 66 | + |
| 67 | + it('crosses month boundaries correctly', () => { |
| 68 | + expect(defaultDailyRange('2026-03-01')).toEqual({ |
| 69 | + startDate: '2026-02-27', |
| 70 | + endDate: '2026-02-28', |
| 71 | + }) |
| 72 | + }) |
| 73 | +}) |
| 74 | + |
| 75 | +describe('utcFirstOfCurrentMonth', () => { |
| 76 | + it('returns the 1st of the month containing the reference date', () => { |
| 77 | + expect(utcFirstOfCurrentMonth('2026-06-30')).toBe('2026-06-01') |
| 78 | + expect(utcFirstOfCurrentMonth('2026-01-15')).toBe('2026-01-01') |
| 79 | + }) |
| 80 | +}) |
| 81 | + |
| 82 | +// ── Criterion 4: 30d BigQuery aggregate query ───────────────────────────────────────────── |
| 83 | +describe('buildPypiDownloads30dSql', () => { |
| 84 | + const sql = buildPypiDownloads30dSql({ startDate: '2026-05-02', endDate: '2026-06-01' }) |
| 85 | + |
| 86 | + it('reads from the public file_downloads table', () => { |
| 87 | + expect(sql).toContain('bigquery-public-data.pypi.file_downloads') |
| 88 | + }) |
| 89 | + |
| 90 | + it('filters the date range on the timestamp partition', () => { |
| 91 | + expect(sql).toContain('DATE(timestamp)') |
| 92 | + expect(sql).toContain('BETWEEN') |
| 93 | + expect(sql).toContain('2026-05-02') |
| 94 | + expect(sql).toContain('2026-06-01') |
| 95 | + }) |
| 96 | + |
| 97 | + it('excludes bandersnatch mirror traffic while keeping NULL installers', () => { |
| 98 | + expect(sql).toContain("COALESCE(details.installer.name, '') <> 'bandersnatch'") |
| 99 | + }) |
| 100 | + |
| 101 | + it('groups by the PEP 503-normalized project and counts downloads', () => { |
| 102 | + expect(sql).toContain("REGEXP_REPLACE(LOWER(file.project), r'[-_.]+', '-')") |
| 103 | + expect(sql).toMatch(/COUNT\(\*\)\s+AS\s+downloads/i) |
| 104 | + expect(sql).toContain('GROUP BY') |
| 105 | + }) |
| 106 | +}) |
| 107 | + |
| 108 | +// ── Criterion 5: daily BigQuery aggregate query ─────────────────────────────────────────── |
| 109 | +describe('buildPypiDownloadsDailySql', () => { |
| 110 | + const sql = buildPypiDownloadsDailySql({ startDate: '2026-06-01', endDate: '2026-06-03' }) |
| 111 | + |
| 112 | + it('aggregates per project per day', () => { |
| 113 | + expect(sql).toContain('bigquery-public-data.pypi.file_downloads') |
| 114 | + expect(sql).toContain('DATE(timestamp) AS day') |
| 115 | + expect(sql).toContain("COALESCE(details.installer.name, '') <> 'bandersnatch'") |
| 116 | + expect(sql).toContain("REGEXP_REPLACE(LOWER(file.project), r'[-_.]+', '-')") |
| 117 | + expect(sql).toMatch(/COUNT\(\*\)\s+AS\s+downloads/i) |
| 118 | + expect(sql).toContain('GROUP BY project, day') |
| 119 | + }) |
| 120 | + |
| 121 | + it('never pushes our package list into BigQuery — scoping to is_critical is the merge job', () => { |
| 122 | + expect(sql).not.toContain('UNNEST') |
| 123 | + expect(sql).not.toContain('is_critical') |
| 124 | + }) |
| 125 | +}) |
| 126 | + |
| 127 | +// ── Criterion 6: merge SQL builders ─────────────────────────────────────────────────────── |
| 128 | +describe('buildPypiDownloads30dMergeSql', () => { |
| 129 | + it('emits only the insert when not mirroring', () => { |
| 130 | + const stmts = buildPypiDownloads30dMergeSql({ |
| 131 | + startDate: '2026-05-02', |
| 132 | + endDate: '2026-06-01', |
| 133 | + mirrorToPackages: false, |
| 134 | + }) |
| 135 | + expect(stmts).toHaveLength(1) |
| 136 | + const insert = stmts[0] |
| 137 | + expect(insert).toContain('INSERT INTO downloads_last_30d') |
| 138 | + expect(insert).toContain('staging.pypi_downloads_30d_raw') |
| 139 | + expect(insert).toContain("p.ecosystem = 'pypi'") |
| 140 | + expect(insert).toContain('2026-05-02') |
| 141 | + expect(insert).toContain('2026-06-01') |
| 142 | + expect(insert).toContain('ON CONFLICT (purl, end_date) DO UPDATE') |
| 143 | + // PG-side normalization must use the 'g' flag to replace every separator run |
| 144 | + expect(insert).toContain("REGEXP_REPLACE(LOWER(p.name), '[-_.]+', '-', 'g')") |
| 145 | + }) |
| 146 | + |
| 147 | + it('appends a packages mirror update when mirroring the latest window', () => { |
| 148 | + const stmts = buildPypiDownloads30dMergeSql({ |
| 149 | + startDate: '2026-05-02', |
| 150 | + endDate: '2026-06-01', |
| 151 | + mirrorToPackages: true, |
| 152 | + }) |
| 153 | + expect(stmts).toHaveLength(2) |
| 154 | + expect(stmts[1]).toContain('UPDATE packages') |
| 155 | + expect(stmts[1]).toContain('downloads_last_30d') |
| 156 | + expect(stmts[1]).toContain('IS DISTINCT FROM') |
| 157 | + }) |
| 158 | +}) |
| 159 | + |
| 160 | +describe('buildPypiDownloadsDailyMergeSql', () => { |
| 161 | + it('inserts into downloads_daily scoped to critical pypi packages', () => { |
| 162 | + const sql = buildPypiDownloadsDailyMergeSql() |
| 163 | + expect(sql).toContain('INSERT INTO downloads_daily') |
| 164 | + expect(sql).toContain('package_id') |
| 165 | + expect(sql).toContain('staging.pypi_downloads_daily_raw') |
| 166 | + expect(sql).toContain("p.ecosystem = 'pypi'") |
| 167 | + expect(sql).toContain('p.is_critical') |
| 168 | + expect(sql).toContain('ON CONFLICT (package_id, date) DO UPDATE') |
| 169 | + expect(sql).toContain("REGEXP_REPLACE(LOWER(p.name), '[-_.]+', '-', 'g')") |
| 170 | + }) |
| 171 | +}) |
| 172 | + |
| 173 | +// ── Criterion 7: job-kind registry ──────────────────────────────────────────────────────── |
| 174 | +describe('PyPI downloads job kinds', () => { |
| 175 | + it('exposes the two new kind identifiers', () => { |
| 176 | + expect(PYPI_DOWNLOADS_30D_KIND).toBe('pypi_downloads_30d') |
| 177 | + expect(PYPI_DOWNLOADS_DAILY_KIND).toBe('pypi_downloads_daily') |
| 178 | + }) |
| 179 | +}) |
0 commit comments