dsh-dbhub-live 5.1.0 → 5.2.0-dev.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ // Unit tests for the plugin self-update check (lib/update.mjs): npm-first with a
2
+ // GitHub fallback, prerelease-aware hasUpdate, error sanitization, the 30 min
3
+ // throttle, `force`, concurrent-call dedupe and the per-request timeout.
4
+ //
5
+ // Everything is driven through an injected fetch stub and an injected clock, so
6
+ // these tests never touch the network and never wait on a real timer.
7
+
8
+ import test from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import {
12
+ DEFAULT_THROTTLE_MS,
13
+ GITHUB_LATEST_URL,
14
+ NPM_LATEST_URL,
15
+ createUpdateChecker,
16
+ versionFromGithubBody,
17
+ versionFromNpmBody,
18
+ } from '../lib/update.mjs'
19
+
20
+ // ── helpers ───────────────────────────────────────────────────────────────
21
+
22
+ /** A fetch stub that answers from a per-URL table: value = body | {status}. */
23
+ function stubFetch(routes) {
24
+ const calls = []
25
+ const impl = async (url) => {
26
+ calls.push(url)
27
+ const route = routes[url]
28
+ if (!route) throw new Error('unexpected url ' + url)
29
+ if (route instanceof Error) throw route
30
+ if (typeof route === 'function') return route(url)
31
+ const status = typeof route.status === 'number' ? route.status : 200
32
+ return {
33
+ ok: status >= 200 && status < 300,
34
+ status,
35
+ json: async () => route.body,
36
+ }
37
+ }
38
+ impl.calls = calls
39
+ impl.byUrl = (needle) => calls.filter((url) => url.includes(needle)).length
40
+ return impl
41
+ }
42
+
43
+ const ok = (body) => ({ status: 200, body })
44
+ const fail = (status) => ({ status })
45
+
46
+ function checker(overrides = {}) {
47
+ return createUpdateChecker({
48
+ currentVersion: '5.1.1-dev.1',
49
+ now: () => 1000,
50
+ ...overrides,
51
+ })
52
+ }
53
+
54
+ const NPM_OK = { [NPM_LATEST_URL]: ok({ version: '5.2.0' }) }
55
+ const GH_OK = { [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }) }
56
+
57
+ // ── pure body extractors ──────────────────────────────────────────────────
58
+
59
+ test('versionFromNpmBody accepts objects and raw JSON strings, rejects junk', () => {
60
+ assert.equal(versionFromNpmBody({ version: '5.2.0' }), '5.2.0')
61
+ assert.equal(versionFromNpmBody({ version: ' 5.2.0-dev.1 ' }), '5.2.0-dev.1')
62
+ assert.equal(versionFromNpmBody('{"version":"5.2.0"}'), '5.2.0')
63
+ assert.equal(versionFromNpmBody({ version: 'latest' }), '')
64
+ assert.equal(versionFromNpmBody({ version: '' }), '')
65
+ assert.equal(versionFromNpmBody({}), '')
66
+ assert.equal(versionFromNpmBody(null), '')
67
+ assert.equal(versionFromNpmBody('not json'), '')
68
+ })
69
+
70
+ test('versionFromGithubBody strips the release tag v prefix', () => {
71
+ assert.equal(versionFromGithubBody({ tag_name: 'v5.2.0' }), '5.2.0')
72
+ assert.equal(versionFromGithubBody({ tag_name: '5.2.0' }), '5.2.0')
73
+ assert.equal(versionFromGithubBody({ tag_name: 'V5.2.0-dev.3' }), '5.2.0-dev.3')
74
+ assert.equal(versionFromGithubBody('{"tag_name":"v5.2.0"}'), '5.2.0')
75
+ assert.equal(versionFromGithubBody({ tag_name: 'vnext' }), '')
76
+ assert.equal(versionFromGithubBody({ name: 'v5.2.0' }), '')
77
+ assert.equal(versionFromGithubBody(null), '')
78
+ })
79
+
80
+ // ── happy path + fallback ─────────────────────────────────────────────────
81
+
82
+ test('npm success produces the full snapshot', async () => {
83
+ const fetchImpl = stubFetch(NPM_OK)
84
+ const update = checker({ fetchImpl, now: () => 4242 })
85
+ assert.deepEqual(update.snapshot(), {
86
+ latest: '',
87
+ latestSpec: '',
88
+ hasUpdate: false,
89
+ checkedAt: 0,
90
+ source: '',
91
+ error: '',
92
+ }, 'nothing is claimed before the first check')
93
+ const snap = await update.check()
94
+ assert.deepEqual(snap, {
95
+ latest: '5.2.0',
96
+ latestSpec: 'dsh-dbhub-live@5.2.0',
97
+ hasUpdate: true,
98
+ checkedAt: 4242,
99
+ source: 'npm',
100
+ error: '',
101
+ })
102
+ assert.deepEqual(fetchImpl.calls, [NPM_LATEST_URL], 'github is not asked when npm answered')
103
+ // No rejected promise, no throw: check() resolves on every path.
104
+ assert.deepEqual(update.snapshot(), snap)
105
+ })
106
+
107
+ test('npm failure falls back to the GitHub release tag', async () => {
108
+ const fetchImpl = stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }) })
109
+ const snap = await checker({ fetchImpl }).check()
110
+ assert.equal(snap.source, 'github')
111
+ assert.equal(snap.latest, '5.2.0')
112
+ assert.equal(snap.latestSpec, 'dsh-dbhub-live@5.2.0')
113
+ assert.equal(snap.hasUpdate, true)
114
+ assert.equal(snap.error, '', 'a successful fallback clears the npm error')
115
+ })
116
+
117
+ test('an invalid npm payload also falls back instead of claiming a version', async () => {
118
+ const fetchImpl = stubFetch({
119
+ [NPM_LATEST_URL]: ok({ version: 'latest' }),
120
+ [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }),
121
+ })
122
+ const snap = await checker({ fetchImpl }).check()
123
+ assert.equal(snap.latest, '5.2.0')
124
+ assert.equal(snap.source, 'github')
125
+ assert.equal(snap.error, '')
126
+ })
127
+
128
+ test('both sources failing yields a short error and KEEPS the previous result', async () => {
129
+ let clock = 1000
130
+ let current = stubFetch(NPM_OK)
131
+ // One checker; the stub it calls is swapped between runs (like a network
132
+ // that worked once and then went down).
133
+ const flaky = createUpdateChecker({
134
+ currentVersion: '5.1.1-dev.1',
135
+ now: () => clock,
136
+ throttleMs: 0,
137
+ fetchImpl: (url, init) => current(url, init),
138
+ })
139
+ const good = await flaky.check()
140
+ assert.equal(good.latest, '5.2.0')
141
+ assert.equal(good.error, '')
142
+
143
+ current = stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: fail(500) })
144
+ clock = 60_000
145
+ const bad = await flaky.check(true)
146
+ assert.equal(bad.latest, '5.2.0', 'stale latest preserved')
147
+ assert.equal(bad.latestSpec, 'dsh-dbhub-live@5.2.0')
148
+ assert.equal(bad.source, 'npm')
149
+ assert.equal(bad.hasUpdate, true)
150
+ assert.equal(bad.checkedAt, 60_000, 'checkedAt advances even on failure')
151
+ assert.equal(bad.error, 'npm: HTTP 503; github: HTTP 500')
152
+
153
+ // A brand-new checker has nothing to preserve: a failure leaves it empty.
154
+ const fresh = createUpdateChecker({
155
+ currentVersion: '5.1.1-dev.1',
156
+ now: () => clock,
157
+ fetchImpl: stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: fail(500) }),
158
+ })
159
+ const empty = await fresh.check(true)
160
+ assert.equal(empty.latest, '')
161
+ assert.equal(empty.latestSpec, '')
162
+ assert.equal(empty.hasUpdate, false)
163
+ assert.equal(empty.source, '')
164
+ assert.equal(empty.error, 'npm: HTTP 503; github: HTTP 500')
165
+ })
166
+
167
+ test('hasUpdate is strictly-newer and prerelease-aware', async () => {
168
+ const cases = [
169
+ // current, latest, expected hasUpdate
170
+ ['5.1.1-dev.1', '5.2.0', true, 'a newer release'],
171
+ ['5.2.0-dev.1', '5.2.0', true, 'the release supersedes its own dev build'],
172
+ ['5.2.0', '5.2.0-dev.2', false, 'a dev build is older than the release'],
173
+ ['5.2.0', '5.2.0', false, 'equal versions'],
174
+ ['5.2.0', '5.1.0', false, 'an older release'],
175
+ ['5.1.0', '5.2.0-dev.1', true, 'a newer dev build'],
176
+ ['', '5.2.0', false, 'unknown current version never claims an update'],
177
+ ]
178
+ for (const [currentVersion, latest, expected, why] of cases) {
179
+ const fetchImpl = stubFetch({ [NPM_LATEST_URL]: ok({ version: latest }) })
180
+ const snap = await createUpdateChecker({ currentVersion, fetchImpl, now: () => 1 }).check(true)
181
+ assert.equal(snap.hasUpdate, expected, currentVersion + ' -> ' + latest + ' (' + why + ')')
182
+ assert.equal(snap.latest, latest)
183
+ }
184
+ })
185
+
186
+ // ── throttle / force / dedupe ─────────────────────────────────────────────
187
+
188
+ test('a second check inside the throttle window makes no request', async () => {
189
+ let clock = 10_000
190
+ const fetchImpl = stubFetch(NPM_OK)
191
+ const update = createUpdateChecker({ fetchImpl, now: () => clock })
192
+ const first = await update.check()
193
+ assert.equal(fetchImpl.calls.length, 1)
194
+
195
+ clock = 10_000 + DEFAULT_THROTTLE_MS - 1
196
+ const reused = await update.check()
197
+ assert.deepEqual(reused, first, 'the snapshot is reused verbatim')
198
+ assert.equal(fetchImpl.calls.length, 1, 'no new request inside the throttle window')
199
+
200
+ // `force` always refetches, even immediately.
201
+ const forced = await update.check(true)
202
+ assert.equal(fetchImpl.calls.length, 2)
203
+ assert.equal(forced.checkedAt, clock)
204
+ })
205
+
206
+ test('concurrent checks share one in-flight request', async () => {
207
+ let release
208
+ const gate = new Promise((resolve) => { release = resolve })
209
+ const fetchImpl = stubFetch({
210
+ [NPM_LATEST_URL]: async () => {
211
+ await gate
212
+ return { ok: true, status: 200, json: async () => ({ version: '5.2.0' }) }
213
+ },
214
+ })
215
+ const update = createUpdateChecker({ fetchImpl, now: () => 7 })
216
+ const first = update.check(true)
217
+ const second = update.check(true)
218
+ assert.equal(first, second, 'the same promise is handed to every concurrent caller')
219
+ release()
220
+ const [a, b] = await Promise.all([first, second])
221
+ assert.deepEqual(a, b)
222
+ assert.equal(fetchImpl.calls.length, 1, 'one request burst, not one per caller')
223
+ })
224
+
225
+ test('a failing check never rejects and error text is sanitized', async () => {
226
+ const fetchImpl = stubFetch({
227
+ [NPM_LATEST_URL]: new Error('boom https://registry.npmjs.org/dsh-dbhub-live/latest?token=abc123'),
228
+ [GITHUB_LATEST_URL]: new Error('fetch failed'),
229
+ })
230
+ const snap = await createUpdateChecker({ fetchImpl, now: () => 5 }).check(true)
231
+ assert.equal(snap.latest, '')
232
+ assert.equal(snap.source, '')
233
+ assert.match(snap.error, /^npm: /)
234
+ assert.match(snap.error, /github: /)
235
+ assert.ok(!snap.error.includes('http'), 'no URL survives into the view: ' + snap.error)
236
+ assert.ok(!snap.error.includes('token='), 'no query token survives into the view: ' + snap.error)
237
+ assert.ok(snap.error.length <= 200, 'the message stays short')
238
+ })
239
+
240
+ test('a request that outlives the budget is aborted and reported as timeout', async () => {
241
+ const fetchImpl = (url, init) => new Promise((resolve, reject) => {
242
+ const signal = init && init.signal
243
+ if (!signal) {
244
+ reject(new Error('no abort signal was passed'))
245
+ return
246
+ }
247
+ const abort = () => {
248
+ const error = new Error('The operation was aborted')
249
+ error.name = 'AbortError'
250
+ reject(error)
251
+ }
252
+ if (signal.aborted) abort()
253
+ else signal.addEventListener('abort', abort)
254
+ })
255
+ const snap = await createUpdateChecker({ fetchImpl, now: () => 1, timeoutMs: 20 }).check(true)
256
+ assert.equal(snap.latest, '')
257
+ assert.equal(snap.source, '')
258
+ assert.equal(snap.error, 'npm: timeout; github: timeout')
259
+ assert.equal(snap.checkedAt, 1)
260
+ })
261
+
262
+ test('snapshot() returns a detached copy', async () => {
263
+ const update = checker({ fetchImpl: stubFetch(NPM_OK) })
264
+ await update.check(true)
265
+ const snap = update.snapshot()
266
+ snap.latest = 'tampered'
267
+ snap.hasUpdate = false
268
+ assert.equal(update.snapshot().latest, '5.2.0')
269
+ assert.equal(update.snapshot().hasUpdate, true)
270
+ })