@prosopo/ipinfo 0.2.40 → 0.3.1

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.
Files changed (52) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +12 -10
  2. package/.turbo/turbo-build$colon$tsc.log +10 -10
  3. package/.turbo/turbo-build.log +14 -11
  4. package/CHANGELOG.md +28 -0
  5. package/dist/IpInfoService.d.ts +8 -1
  6. package/dist/IpInfoService.d.ts.map +1 -1
  7. package/dist/IpInfoService.js +77 -84
  8. package/dist/IpInfoService.js.map +1 -1
  9. package/dist/_virtual/_rolldown/runtime.js +3 -0
  10. package/dist/backends/ipapi.d.ts +6 -0
  11. package/dist/backends/ipapi.d.ts.map +1 -1
  12. package/dist/backends/ipapi.js +117 -104
  13. package/dist/backends/ipapi.js.map +1 -1
  14. package/dist/backends/maxmind.d.ts +4 -0
  15. package/dist/backends/maxmind.d.ts.map +1 -1
  16. package/dist/backends/maxmind.js +126 -145
  17. package/dist/backends/maxmind.js.map +1 -1
  18. package/dist/cjs/IpInfoService.cjs +80 -86
  19. package/dist/cjs/backends/ipapi.cjs +118 -104
  20. package/dist/cjs/backends/maxmind.cjs +124 -165
  21. package/dist/cjs/index.cjs +7 -3
  22. package/dist/index.d.ts +6 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -4
  25. package/dist/index.js.map +1 -1
  26. package/dist/tests/ipInfoService.unit.test.d.ts +2 -0
  27. package/dist/tests/ipInfoService.unit.test.d.ts.map +1 -0
  28. package/dist/tests/ipInfoService.unit.test.js +244 -0
  29. package/dist/tests/ipInfoService.unit.test.js.map +1 -0
  30. package/dist/tests/ipapi.unit.test.d.ts +2 -0
  31. package/dist/tests/ipapi.unit.test.d.ts.map +1 -0
  32. package/dist/tests/ipapi.unit.test.js +337 -0
  33. package/dist/tests/ipapi.unit.test.js.map +1 -0
  34. package/dist/tests/ipinfo.test-d.d.ts +2 -0
  35. package/dist/tests/ipinfo.test-d.d.ts.map +1 -0
  36. package/dist/tests/ipinfo.test-d.js +97 -0
  37. package/dist/tests/ipinfo.test-d.js.map +1 -0
  38. package/dist/tests/maxmind.unit.test.d.ts +2 -0
  39. package/dist/tests/maxmind.unit.test.d.ts.map +1 -0
  40. package/dist/tests/maxmind.unit.test.js +391 -0
  41. package/dist/tests/maxmind.unit.test.js.map +1 -0
  42. package/package.json +10 -8
  43. package/src/IpInfoService.ts +19 -2
  44. package/src/backends/ipapi.ts +57 -10
  45. package/src/backends/maxmind.ts +22 -4
  46. package/src/index.ts +9 -1
  47. package/src/tests/ipInfoService.unit.test.ts +422 -0
  48. package/src/tests/ipapi.unit.test.ts +509 -0
  49. package/src/tests/ipinfo.test-d.ts +189 -0
  50. package/src/tests/maxmind.unit.test.ts +562 -0
  51. package/tsconfig.tsbuildinfo +1 -1
  52. package/vite.test.config.ts +18 -0
@@ -0,0 +1,509 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // Testing strategy: fetch is the only seam that touches the outside world, so
16
+ // it is injected and everything else runs for real — including JSON parsing and
17
+ // the AbortController timeout, which is driven by a fetch that genuinely
18
+ // respects the signal rather than by a stubbed clock.
19
+ //
20
+ // The response body is deliberately treated as untrusted here: it is typed as
21
+ // IPApiResponse but arrives from `response.json()`, which casts rather than
22
+ // validates, so the tests cover fields the type claims are required going
23
+ // missing on the wire.
24
+
25
+ import type { IPApiResponse, IPInfoResult } from "@prosopo/types";
26
+ import { describe, expect, it, vi } from "vitest";
27
+ import {
28
+ DEFAULT_TIMEOUT_MS,
29
+ type FetchFn,
30
+ IpapiBackend,
31
+ parseAbuserScore,
32
+ } from "../backends/ipapi.js";
33
+
34
+ const BASE_URL = "https://api.ipapi.test/";
35
+ const IP = "8.8.8.8";
36
+
37
+ /** Minimal valid upstream payload; individual tests layer fields on top. */
38
+ const baseResponse = (
39
+ overrides: Partial<IPApiResponse> = {},
40
+ ): IPApiResponse => ({
41
+ ip: IP,
42
+ rir: "ARIN",
43
+ is_bogon: false,
44
+ is_mobile: false,
45
+ is_satellite: false,
46
+ is_crawler: false,
47
+ is_datacenter: false,
48
+ is_tor: false,
49
+ is_proxy: false,
50
+ is_vpn: false,
51
+ is_abuser: false,
52
+ elapsed_ms: 1,
53
+ ...overrides,
54
+ });
55
+
56
+ /** A fetch that answers with the given JSON body and status. */
57
+ const respondWith = (
58
+ body: unknown,
59
+ init: { status?: number; statusText?: string } = {},
60
+ ): FetchFn => {
61
+ return async (): Promise<globalThis.Response> =>
62
+ new Response(JSON.stringify(body), {
63
+ status: init.status ?? 200,
64
+ statusText: init.statusText ?? "OK",
65
+ headers: { "Content-Type": "application/json" },
66
+ });
67
+ };
68
+
69
+ /** A fetch that never answers until the caller's signal aborts. */
70
+ const respondNever: FetchFn = (_url: string, init: RequestInit) =>
71
+ new Promise<globalThis.Response>((_resolve, reject) => {
72
+ init.signal?.addEventListener("abort", () => {
73
+ // Matches what the platform throws on an aborted fetch: the backend
74
+ // keys its timeout branch off the name, not the type.
75
+ const error = new Error("The operation was aborted");
76
+ error.name = "AbortError";
77
+ reject(error);
78
+ });
79
+ });
80
+
81
+ const backend = (
82
+ fetchFn: FetchFn,
83
+ extra: { apiKey?: string; timeoutMs?: number } = {},
84
+ ): IpapiBackend =>
85
+ new IpapiBackend({ baseUrl: BASE_URL, fetch: fetchFn, ...extra });
86
+
87
+ /** Narrow a response to the success branch, failing loudly if it is an error. */
88
+ const expectValid = (
89
+ response: Awaited<ReturnType<IpapiBackend["lookup"]>>,
90
+ ): IPInfoResult => {
91
+ if (!response.isValid) {
92
+ throw new Error(`expected a valid result, got: ${response.error}`);
93
+ }
94
+ return response;
95
+ };
96
+
97
+ describe("parseAbuserScore", () => {
98
+ it("reads the numeric prefix of a scored string", () => {
99
+ expect(parseAbuserScore("0.0012 (Low)")).toBeCloseTo(0.0012);
100
+ });
101
+
102
+ it("reads a bare number with no qualifier", () => {
103
+ expect(parseAbuserScore("0.5")).toBe(0.5);
104
+ });
105
+
106
+ it("reports a missing score as unknown rather than throwing", () => {
107
+ // The field is declared required but comes from unvalidated JSON. It used
108
+ // to be dereferenced directly, so an absent score threw and the throw was
109
+ // caught far above as a generic parsing error — discarding an otherwise
110
+ // complete lookup.
111
+ expect(parseAbuserScore(undefined)).toBeUndefined();
112
+ });
113
+
114
+ it("reports an empty string as unknown", () => {
115
+ expect(parseAbuserScore("")).toBeUndefined();
116
+ });
117
+
118
+ it("reports a non-numeric score as unknown, never NaN", () => {
119
+ // NaN would be the worst outcome: callers compare this against
120
+ // thresholds, and every comparison against NaN is false, so the IP would
121
+ // silently pass checks it should not.
122
+ expect(parseAbuserScore("unknown")).toBeUndefined();
123
+ expect(parseAbuserScore("(Low)")).toBeUndefined();
124
+ });
125
+
126
+ it("distinguishes a genuine zero from an unknown score", () => {
127
+ // 0 means "measured, and clean" on the 0..1 scale; undefined means "we
128
+ // have no measurement". Collapsing the two would assert cleanliness that
129
+ // was never established.
130
+ expect(parseAbuserScore("0 (Very Low)")).toBe(0);
131
+ expect(parseAbuserScore("0")).toBe(0);
132
+ expect(parseAbuserScore(undefined)).toBeUndefined();
133
+ });
134
+ });
135
+
136
+ describe("IpapiBackend.isAvailable", () => {
137
+ it("is available when a base url is configured", () => {
138
+ expect(backend(respondWith(baseResponse())).isAvailable()).toBe(true);
139
+ });
140
+
141
+ it("is unavailable when the base url is blank", () => {
142
+ // An unset env var arrives as "", which must not be treated as a usable
143
+ // endpoint — every lookup would POST to a relative path.
144
+ const blank = new IpapiBackend({ baseUrl: "" });
145
+ expect(blank.isAvailable()).toBe(false);
146
+ });
147
+ });
148
+
149
+ describe("IpapiBackend.lookup request", () => {
150
+ it("posts the ip as JSON to the configured url", async () => {
151
+ const fetchFn = vi.fn<FetchFn>(respondWith(baseResponse()));
152
+ await backend(fetchFn).lookup(IP);
153
+
154
+ const [url, init] = fetchFn.mock.calls[0] ?? [];
155
+ expect(url).toBe(BASE_URL);
156
+ expect(init?.method).toBe("POST");
157
+ expect(JSON.parse(String(init?.body))).toEqual({ q: IP });
158
+ });
159
+
160
+ it("includes the api key when one is configured", async () => {
161
+ const fetchFn = vi.fn<FetchFn>(respondWith(baseResponse()));
162
+ await backend(fetchFn, { apiKey: "secret" }).lookup(IP);
163
+
164
+ const init = fetchFn.mock.calls[0]?.[1];
165
+ expect(JSON.parse(String(init?.body))).toEqual({ q: IP, key: "secret" });
166
+ });
167
+
168
+ it("omits the key entirely when none is configured", async () => {
169
+ // Sending `key: undefined` would serialise the field away anyway, but an
170
+ // empty-string key would be sent and rejected upstream.
171
+ const fetchFn = vi.fn<FetchFn>(respondWith(baseResponse()));
172
+ await backend(fetchFn).lookup(IP);
173
+
174
+ expect(
175
+ JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body)),
176
+ ).not.toHaveProperty("key");
177
+ });
178
+
179
+ it("asks for and declares JSON", async () => {
180
+ const fetchFn = vi.fn<FetchFn>(respondWith(baseResponse()));
181
+ await backend(fetchFn).lookup(IP);
182
+
183
+ const headers = fetchFn.mock.calls[0]?.[1]?.headers;
184
+ expect(headers).toMatchObject({
185
+ "Content-Type": "application/json",
186
+ Accept: "application/json",
187
+ });
188
+ });
189
+ });
190
+
191
+ describe("IpapiBackend.lookup input validation", () => {
192
+ it("rejects an empty ip without calling out", async () => {
193
+ // Length 0: the upstream would answer about the caller's own address,
194
+ // which is a wrong answer rather than an error.
195
+ const fetchFn = vi.fn<FetchFn>(respondWith(baseResponse()));
196
+ const result = await backend(fetchFn).lookup("");
197
+
198
+ expect(result).toEqual({
199
+ isValid: false,
200
+ error: "Invalid IP address provided",
201
+ ip: "undefined",
202
+ });
203
+ expect(fetchFn).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it("does not send a lookup for a non-string ip from an untyped caller", async () => {
207
+ // This package is consumed from JavaScript too, where the type guard is
208
+ // the only thing standing between a bad value and the upstream.
209
+ const fetchFn = vi.fn<FetchFn>(respondWith(baseResponse()));
210
+ const untyped: (ip: unknown) => Promise<unknown> = (ip: unknown) =>
211
+ backend(fetchFn).lookup(ip as string);
212
+
213
+ await expect(untyped(null)).resolves.toMatchObject({ isValid: false });
214
+ expect(fetchFn).not.toHaveBeenCalled();
215
+ });
216
+ });
217
+
218
+ describe("IpapiBackend.lookup responses", () => {
219
+ it("maps a full response onto the shared result shape", async () => {
220
+ const result = expectValid(
221
+ await backend(
222
+ respondWith(
223
+ baseResponse({
224
+ is_vpn: true,
225
+ is_datacenter: true,
226
+ company: {
227
+ name: "Example Co",
228
+ abuser_score: "0.01 (Low)",
229
+ domain: "example.com",
230
+ type: "hosting",
231
+ network: "8.8.8.0/24",
232
+ whois: "",
233
+ },
234
+ asn: {
235
+ asn: 15169,
236
+ abuser_score: "0.002 (Very Low)",
237
+ route: "8.8.8.0/24",
238
+ descr: "",
239
+ country: "US",
240
+ active: true,
241
+ org: "Google LLC",
242
+ domain: "google.com",
243
+ abuse: "",
244
+ type: "hosting",
245
+ created: "",
246
+ updated: "",
247
+ rir: "ARIN",
248
+ whois: "",
249
+ },
250
+ location: {
251
+ is_eu_member: false,
252
+ calling_code: "1",
253
+ currency_code: "USD",
254
+ continent: "NA",
255
+ country: "United States",
256
+ country_code: "US",
257
+ state: "California",
258
+ city: "Mountain View",
259
+ latitude: 37.4,
260
+ longitude: -122.1,
261
+ zip: "94035",
262
+ timezone: "America/Los_Angeles",
263
+ local_time: "",
264
+ local_time_unix: 0,
265
+ is_dst: false,
266
+ },
267
+ vpn: {
268
+ ip: IP,
269
+ service: "ExampleVPN",
270
+ url: "",
271
+ type: "exit_node",
272
+ last_seen: 0,
273
+ last_seen_str: "",
274
+ country_code: "US",
275
+ city_name: "Mountain View",
276
+ latitude: 37.4,
277
+ longitude: -122.1,
278
+ },
279
+ }),
280
+ ),
281
+ ).lookup(IP),
282
+ );
283
+
284
+ expect(result).toMatchObject({
285
+ ip: IP,
286
+ isValid: true,
287
+ isVPN: true,
288
+ isDatacenter: true,
289
+ providerName: "Example Co",
290
+ providerType: "hosting",
291
+ asnNumber: 15169,
292
+ asnOrganization: "Google LLC",
293
+ country: "United States",
294
+ countryCode: "US",
295
+ region: "California",
296
+ city: "Mountain View",
297
+ timezone: "America/Los_Angeles",
298
+ vpnService: "ExampleVPN",
299
+ vpnType: "exit_node",
300
+ });
301
+ expect(result.abuserScore).toBeCloseTo(0.002);
302
+ expect(result.companyAbuserScore).toBeCloseTo(0.01);
303
+ });
304
+
305
+ it("survives a response missing every optional section", async () => {
306
+ // The upstream omits company/asn/location entirely for many IPs; the
307
+ // mapping must degrade to undefined fields, not fail.
308
+ const result = expectValid(
309
+ await backend(respondWith(baseResponse())).lookup(IP),
310
+ );
311
+
312
+ expect(result.isValid).toBe(true);
313
+ expect(result.country).toBeUndefined();
314
+ expect(result.asnNumber).toBeUndefined();
315
+ expect(result.providerName).toBeUndefined();
316
+ expect(result.abuserScore).toBeUndefined();
317
+ expect(result.companyAbuserScore).toBeUndefined();
318
+ });
319
+
320
+ it("keeps a successful lookup when the abuser scores are absent", async () => {
321
+ // The regression this package's fix addresses: abuser_score is declared
322
+ // required, but a response without it must not cost the caller the
323
+ // geolocation and threat data that did arrive.
324
+ const result = expectValid(
325
+ await backend(
326
+ respondWith(
327
+ baseResponse({
328
+ asn: {
329
+ asn: 64512,
330
+ route: "",
331
+ descr: "",
332
+ country: "US",
333
+ active: true,
334
+ org: "Test",
335
+ domain: "",
336
+ abuse: "",
337
+ type: "isp",
338
+ created: "",
339
+ updated: "",
340
+ rir: "ARIN",
341
+ whois: "",
342
+ } as IPApiResponse["asn"],
343
+ }),
344
+ ),
345
+ ).lookup(IP),
346
+ );
347
+
348
+ expect(result.isValid).toBe(true);
349
+ expect(result.asnNumber).toBe(64512);
350
+ expect(result.abuserScore).toBeUndefined();
351
+ });
352
+
353
+ it("prefers the company name but falls back to the datacenter", async () => {
354
+ const result = expectValid(
355
+ await backend(
356
+ respondWith(
357
+ baseResponse({
358
+ datacenter: { datacenter: "AWS", network: "1.2.3.0/24" },
359
+ }),
360
+ ),
361
+ ).lookup(IP),
362
+ );
363
+
364
+ expect(result.providerName).toBe("AWS");
365
+ // datacenterName is deliberately separate: it is used for strict name
366
+ // comparisons, so it must never inherit a company name.
367
+ expect(result.datacenterName).toBe("AWS");
368
+ });
369
+
370
+ it("reports a bogon address as invalid rather than mapping it", async () => {
371
+ const result = await backend(
372
+ respondWith(baseResponse({ is_bogon: true })),
373
+ ).lookup(IP);
374
+
375
+ expect(result).toEqual({
376
+ isValid: false,
377
+ error: "IP address is bogon (non-routable)",
378
+ ip: IP,
379
+ });
380
+ });
381
+
382
+ it("reports a non-2xx status with the code and text", async () => {
383
+ const result = await backend(
384
+ respondWith({}, { status: 429, statusText: "Too Many Requests" }),
385
+ ).lookup(IP);
386
+
387
+ expect(result).toMatchObject({
388
+ isValid: false,
389
+ ip: IP,
390
+ error: "API request failed with status 429: Too Many Requests",
391
+ });
392
+ });
393
+
394
+ it("reports a 500 without throwing", async () => {
395
+ // Services fail sporadically; a 500 must be an error response, not a
396
+ // rejected promise that the caller has to wrap.
397
+ const result = await backend(
398
+ respondWith({}, { status: 500, statusText: "Internal Server Error" }),
399
+ ).lookup(IP);
400
+
401
+ expect(result.isValid).toBe(false);
402
+ });
403
+
404
+ it("turns malformed JSON into an error response", async () => {
405
+ const malformed: FetchFn = async (): Promise<globalThis.Response> =>
406
+ new Response("not json", { status: 200 });
407
+
408
+ const result = await backend(malformed).lookup(IP);
409
+
410
+ expect(result.isValid).toBe(false);
411
+ expect(result).toMatchObject({ ip: IP });
412
+ if (!result.isValid) {
413
+ expect(result.error).toContain("Network or parsing error");
414
+ }
415
+ });
416
+
417
+ it("turns a network failure into an error response", async () => {
418
+ const failing: FetchFn = async (): Promise<globalThis.Response> => {
419
+ throw new Error("ECONNREFUSED");
420
+ };
421
+
422
+ const result = await backend(failing).lookup(IP);
423
+
424
+ expect(result).toEqual({
425
+ isValid: false,
426
+ error: "Network or parsing error: ECONNREFUSED",
427
+ ip: IP,
428
+ });
429
+ });
430
+
431
+ it("describes a non-Error rejection rather than printing [object Object]", async () => {
432
+ const failing: FetchFn = async (): Promise<globalThis.Response> => {
433
+ // Rejections are not guaranteed to be Errors.
434
+ throw "socket hang up";
435
+ };
436
+
437
+ const result = await backend(failing).lookup(IP);
438
+
439
+ expect(result).toMatchObject({
440
+ isValid: false,
441
+ error: "Network or parsing error: socket hang up",
442
+ });
443
+ });
444
+ });
445
+
446
+ describe("IpapiBackend.lookup timeout", () => {
447
+ it("gives up on a hanging upstream and says so", async () => {
448
+ // An IP lookup sits in the request path: a backend that never answers
449
+ // must not hold the caller open indefinitely.
450
+ const result = await backend(respondNever, { timeoutMs: 20 }).lookup(IP);
451
+
452
+ expect(result).toEqual({
453
+ isValid: false,
454
+ error: "Request timed out after 20ms",
455
+ ip: IP,
456
+ });
457
+ });
458
+
459
+ it("reports the default budget when none is configured", async () => {
460
+ expect(DEFAULT_TIMEOUT_MS).toBe(700);
461
+ const configured = new IpapiBackend({
462
+ baseUrl: BASE_URL,
463
+ fetch: respondNever,
464
+ });
465
+ // Not awaited to completion here — the point is only that the default is
466
+ // the one the message quotes, checked below against a short override.
467
+ const result = await backend(respondNever, {
468
+ timeoutMs: DEFAULT_TIMEOUT_MS,
469
+ }).lookup(IP);
470
+ expect(result).toMatchObject({
471
+ error: `Request timed out after ${DEFAULT_TIMEOUT_MS}ms`,
472
+ });
473
+ expect(configured.isAvailable()).toBe(true);
474
+ }, 5000);
475
+
476
+ it("does not time out a response that arrives in time", async () => {
477
+ const slowButFine: FetchFn = async (): Promise<globalThis.Response> => {
478
+ await new Promise<void>((resolve) => {
479
+ setTimeout(resolve, 5);
480
+ });
481
+ return new Response(JSON.stringify(baseResponse()), { status: 200 });
482
+ };
483
+
484
+ const result = await backend(slowButFine, { timeoutMs: 200 }).lookup(IP);
485
+
486
+ expect(result.isValid).toBe(true);
487
+ });
488
+
489
+ it("passes an abort signal the upstream can observe", async () => {
490
+ const fetchFn = vi.fn<FetchFn>(respondWith(baseResponse()));
491
+ await backend(fetchFn).lookup(IP);
492
+
493
+ expect(fetchFn.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
494
+ });
495
+ });
496
+
497
+ describe("IpapiBackend default fetch", () => {
498
+ it("uses the global fetch when none is injected", async () => {
499
+ // Exercises the un-injected path end to end: port 1 on loopback refuses
500
+ // immediately, so the real fetch fails fast and its rejection must be
501
+ // contained as an invalid result rather than thrown at the caller.
502
+ const result = await new IpapiBackend({
503
+ baseUrl: "http://127.0.0.1:1/",
504
+ timeoutMs: 2000,
505
+ }).lookup(IP);
506
+
507
+ expect(result.isValid).toBe(false);
508
+ });
509
+ });
@@ -0,0 +1,189 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // These assert the shape consumers actually import — the package entrypoint —
16
+ // rather than the internal modules, so a barrel that stops re-exporting
17
+ // something fails here rather than at a downstream build.
18
+
19
+ import type { IPInfoResponse } from "@prosopo/types";
20
+ import { assertType, describe, expectTypeOf, it } from "vitest";
21
+ // Not re-exported: an internal helper, asserted here because the response type
22
+ // declares the field it parses as required while the wire does not supply it.
23
+ import { parseAbuserScore } from "../backends/ipapi.js";
24
+ import {
25
+ IpInfoService,
26
+ IpapiBackend,
27
+ MaxMindBackend,
28
+ isNonRoutable,
29
+ } from "../index.js";
30
+ import type {
31
+ FetchFn,
32
+ IIpInfoService,
33
+ IpInfoBackends,
34
+ IpInfoServiceConfig,
35
+ OpenReader,
36
+ } from "../index.js";
37
+
38
+ describe("IpInfoService", () => {
39
+ it("satisfies the interface it claims to implement", () => {
40
+ expectTypeOf<IpInfoService>().toMatchTypeOf<IIpInfoService>();
41
+ });
42
+
43
+ it("takes a config and optional injected backends", () => {
44
+ expectTypeOf(IpInfoService).toBeConstructibleWith({});
45
+ expectTypeOf(IpInfoService).toBeConstructibleWith({}, {});
46
+ expectTypeOf(IpInfoService).toBeConstructibleWith(
47
+ { ipapiUrl: "https://api.ipapi.is" },
48
+ { maxmind: null, ipapi: null },
49
+ );
50
+ });
51
+
52
+ it("resolves lookup to the shared response union, never a bare result", () => {
53
+ // The union is the point: a consumer must be forced to check isValid
54
+ // before reading any geolocation field.
55
+ expectTypeOf<IpInfoService["lookup"]>().returns.toEqualTypeOf<
56
+ Promise<IPInfoResponse>
57
+ >();
58
+ });
59
+
60
+ it("exposes initialize and isAvailable with no arguments", () => {
61
+ expectTypeOf<IpInfoService["initialize"]>().parameters.toEqualTypeOf<[]>();
62
+ expectTypeOf<IpInfoService["initialize"]>().returns.toEqualTypeOf<
63
+ Promise<void>
64
+ >();
65
+ expectTypeOf<
66
+ IpInfoService["isAvailable"]
67
+ >().returns.toEqualTypeOf<boolean>();
68
+ });
69
+
70
+ it("keeps every config field optional", () => {
71
+ // An entirely unconfigured service is a supported state — it degrades to
72
+ // "no backend available" rather than failing to compile.
73
+ assertType<IpInfoServiceConfig>({});
74
+ });
75
+
76
+ it("rejects an unknown config key", () => {
77
+ // Catches a renamed option silently becoming a no-op.
78
+ // @ts-expect-error maxmindDbPath is not an option
79
+ assertType<IpInfoServiceConfig>({ maxmindDbPath: "/city.mmdb" });
80
+ });
81
+
82
+ it("rejects a config value of the wrong type", () => {
83
+ // @ts-expect-error the url is a string, not a URL
84
+ assertType<IpInfoServiceConfig>({ ipapiUrl: new URL("https://x.test") });
85
+ });
86
+ });
87
+
88
+ describe("IpInfoBackends", () => {
89
+ it("accepts null for a backend that is deliberately absent", () => {
90
+ // null and undefined must both be spellable: a test that wants exactly
91
+ // one backend needs to say so without a cast.
92
+ assertType<IpInfoBackends>({ maxmind: null, ipapi: null });
93
+ assertType<IpInfoBackends>({});
94
+ });
95
+
96
+ it("does not accept an arbitrary object as a backend", () => {
97
+ // The seam is typed to the real classes so a stub cannot drift out of
98
+ // step with them unnoticed.
99
+ // @ts-expect-error a plain object is not a MaxMindBackend
100
+ assertType<IpInfoBackends>({ maxmind: { lookup: () => undefined } });
101
+ });
102
+ });
103
+
104
+ describe("isNonRoutable", () => {
105
+ it("takes exactly one string and returns a boolean", () => {
106
+ expectTypeOf(isNonRoutable).parameters.toEqualTypeOf<[string]>();
107
+ expectTypeOf(isNonRoutable).returns.toEqualTypeOf<boolean>();
108
+ });
109
+ });
110
+
111
+ describe("parseAbuserScore", () => {
112
+ it("accepts the optional upstream field", () => {
113
+ // undefined must be in the parameter type: the wire does not guarantee
114
+ // the field even though the response type declares it required.
115
+ expectTypeOf(parseAbuserScore).parameters.toEqualTypeOf<
116
+ [string | undefined]
117
+ >();
118
+ });
119
+
120
+ it("returns an optional number, so callers must handle 'unknown'", () => {
121
+ // The undefined in the return type is the point: it forces every caller
122
+ // to decide what an unmeasured score means instead of silently reading
123
+ // it as the clean end of the 0..1 scale.
124
+ expectTypeOf(parseAbuserScore).returns.toEqualTypeOf<number | undefined>();
125
+ expectTypeOf(parseAbuserScore(undefined)).toEqualTypeOf<
126
+ number | undefined
127
+ >();
128
+ });
129
+ });
130
+
131
+ describe("FetchFn", () => {
132
+ it("matches the global fetch closely enough to be its default", () => {
133
+ // If it did not, the `?? globalThis.fetch` fallback would need a cast,
134
+ // and the seam would stop describing what actually runs in production.
135
+ expectTypeOf<typeof globalThis.fetch>().toMatchTypeOf<FetchFn>();
136
+ });
137
+
138
+ it("returns the global Response, not a hand-rolled shape", () => {
139
+ expectTypeOf<FetchFn>().returns.toEqualTypeOf<
140
+ Promise<globalThis.Response>
141
+ >();
142
+ });
143
+
144
+ it("takes a url and an init", () => {
145
+ expectTypeOf<FetchFn>().parameters.toEqualTypeOf<[string, RequestInit]>();
146
+ });
147
+ });
148
+
149
+ describe("OpenReader", () => {
150
+ it("takes a path and resolves to a reader", () => {
151
+ expectTypeOf<OpenReader>().parameters.toEqualTypeOf<[string]>();
152
+ expectTypeOf<OpenReader>().returns.toMatchTypeOf<Promise<unknown>>();
153
+ });
154
+ });
155
+
156
+ describe("backend constructors", () => {
157
+ it("require a base url for ipapi and accept the injection seams", () => {
158
+ expectTypeOf(IpapiBackend).toBeConstructibleWith({
159
+ baseUrl: "https://x.test",
160
+ });
161
+ expectTypeOf(IpapiBackend).toBeConstructibleWith({
162
+ baseUrl: "https://x.test",
163
+ apiKey: "k",
164
+ timeoutMs: 100,
165
+ fetch: async (): Promise<globalThis.Response> => new Response(),
166
+ });
167
+ });
168
+
169
+ it("leave every MaxMind option optional", () => {
170
+ // A backend with neither database path is legal and simply unavailable.
171
+ expectTypeOf(MaxMindBackend).toBeConstructibleWith({});
172
+ expectTypeOf(MaxMindBackend).toBeConstructibleWith({
173
+ cityDbPath: "/city.mmdb",
174
+ asnDbPath: "/asn.mmdb",
175
+ });
176
+ });
177
+
178
+ it("give both backends the same lookup signature as the service", () => {
179
+ // The service routes between them, so any divergence would show up as a
180
+ // runtime surprise rather than a compile error.
181
+ expectTypeOf<MaxMindBackend["lookup"]>().parameters.toEqualTypeOf<
182
+ [string]
183
+ >();
184
+ expectTypeOf<IpapiBackend["lookup"]>().parameters.toEqualTypeOf<[string]>();
185
+ expectTypeOf<IpapiBackend["lookup"]>().returns.toEqualTypeOf<
186
+ Promise<IPInfoResponse>
187
+ >();
188
+ });
189
+ });