airport-utils 1.2.0 → 1.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.
@@ -1,52 +1,69 @@
1
1
  import { execSync } from 'child_process';
2
+ import fs from 'fs';
2
3
  import path from 'path';
4
+ import type * as AirportUtils from '../src/index';
5
+
6
+ function ensureBuild(): void {
7
+ const esmPath = path.resolve(__dirname, '../dist/esm/index.js');
8
+ const cjsPath = path.resolve(__dirname, '../dist/cjs/index.js');
9
+ if (fs.existsSync(esmPath) && fs.existsSync(cjsPath)) return;
10
+ execSync('npm run build', { stdio: 'inherit' });
11
+ }
3
12
 
4
13
  // Test CommonJS build
5
14
  describe('CommonJS build (dist/cjs)', () => {
6
- // eslint-disable-next-line @typescript-eslint/no-var-requires
7
- const cjs = require('../dist/cjs/index.js') as any;
8
- const { convertToUTC, convertLocalToUTCByZone, getAirportInfo } = cjs;
9
-
10
- it('convertToUTC works in CommonJS build', () => {
11
- expect(convertToUTC('2025-05-02T14:30', 'JFK')).toBe('2025-05-02T18:30:00Z');
12
- });
13
-
14
- it('convertLocalToUTCByZone works in CommonJS build', () => {
15
- expect(convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London'))
16
- .toBe('2025-05-02T13:30:00Z');
17
- });
18
-
19
- it('getAirportInfo works in CommonJS build', () => {
20
- const info = getAirportInfo('JFK');
21
- expect(info).toHaveProperty('timezone');
22
- expect(info).toHaveProperty('latitude');
23
- expect(info).toHaveProperty('longitude');
24
- });
15
+ beforeAll(() => {
16
+ ensureBuild();
17
+ });
18
+
19
+ const cjs = require('../dist/cjs/index.js') as typeof AirportUtils;
20
+ const { convertToUTC, convertLocalToUTCByZone, getAirportInfo } = cjs;
21
+
22
+ it('convertToUTC works in CommonJS build', () => {
23
+ expect(convertToUTC('2025-05-02T14:30', 'JFK')).toBe('2025-05-02T18:30:00Z');
24
+ });
25
+
26
+ it('convertLocalToUTCByZone works in CommonJS build', () => {
27
+ expect(convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London')).toBe(
28
+ '2025-05-02T13:30:00Z'
29
+ );
30
+ });
31
+
32
+ it('getAirportInfo works in CommonJS build', () => {
33
+ const info = getAirportInfo('JFK');
34
+ expect(info).toHaveProperty('timezone');
35
+ expect(info).toHaveProperty('latitude');
36
+ expect(info).toHaveProperty('longitude');
37
+ });
25
38
  });
26
39
 
27
40
  // Test ESM build using a subprocess to dynamically import the file
28
41
  describe('ESM build (dist/esm)', () => {
29
- const esmPath = path.resolve(__dirname, '../dist/esm/index.js');
30
- const fileUrl = 'file://' + esmPath;
31
-
32
- it('convertToUTC works in ESM build', () => {
33
- const cmd = `node -e "(async()=>{ const m = await import('${fileUrl}'); console.log(m.convertToUTC('2025-05-02T14:30','JFK')); })()"`;
34
- const result = execSync(cmd, { encoding: 'utf-8' }).trim();
35
- expect(result).toBe('2025-05-02T18:30:00Z');
36
- });
37
-
38
- it('convertLocalToUTCByZone works in ESM build', () => {
39
- const cmd = `node -e "(async()=>{ const m = await import('${fileUrl}'); console.log(m.convertLocalToUTCByZone('2025-05-02T14:30:00','Europe/London')); })()"`;
40
- const result = execSync(cmd, { encoding: 'utf-8' }).trim();
41
- expect(result).toBe('2025-05-02T13:30:00Z');
42
- });
43
-
44
- it('getAirportInfo works in ESM build', () => {
45
- const cmd = `node -e "(async()=>{ const m = await import('${fileUrl}'); console.log(JSON.stringify(m.getAirportInfo('JFK'))); })()"`;
46
- const result = execSync(cmd, { encoding: 'utf-8' }).trim();
47
- const info = JSON.parse(result);
48
- expect(info).toHaveProperty('timezone');
49
- expect(info).toHaveProperty('latitude');
50
- expect(info).toHaveProperty('longitude');
51
- });
42
+ beforeAll(() => {
43
+ ensureBuild();
44
+ });
45
+
46
+ const esmPath = path.resolve(__dirname, '../dist/esm/index.js');
47
+ const fileUrl = 'file://' + esmPath;
48
+
49
+ it('convertToUTC works in ESM build', () => {
50
+ const cmd = `node -e "(async()=>{ const m = await import('${fileUrl}'); console.log(m.convertToUTC('2025-05-02T14:30','JFK')); })()"`;
51
+ const result = execSync(cmd, { encoding: 'utf-8' }).trim();
52
+ expect(result).toBe('2025-05-02T18:30:00Z');
53
+ });
54
+
55
+ it('convertLocalToUTCByZone works in ESM build', () => {
56
+ const cmd = `node -e "(async()=>{ const m = await import('${fileUrl}'); console.log(m.convertLocalToUTCByZone('2025-05-02T14:30:00','Europe/London')); })()"`;
57
+ const result = execSync(cmd, { encoding: 'utf-8' }).trim();
58
+ expect(result).toBe('2025-05-02T13:30:00Z');
59
+ });
60
+
61
+ it('getAirportInfo works in ESM build', () => {
62
+ const cmd = `node -e "(async()=>{ const m = await import('${fileUrl}'); console.log(JSON.stringify(m.getAirportInfo('JFK'))); })()"`;
63
+ const result = execSync(cmd, { encoding: 'utf-8' }).trim();
64
+ const info = JSON.parse(result);
65
+ expect(info).toHaveProperty('timezone');
66
+ expect(info).toHaveProperty('latitude');
67
+ expect(info).toHaveProperty('longitude');
68
+ });
52
69
  });
@@ -1,115 +1,130 @@
1
+ import { TZDate } from '@date-fns/tz';
1
2
  import { convertToUTC, convertLocalToUTCByZone } from '../src/converter';
2
- import {
3
- UnknownAirportError,
4
- InvalidTimestampError,
5
- UnknownTimezoneError
6
- } from '../src/errors';
7
- import { timezones } from '../src/mapping/timezones';
8
-
9
- const { TZDate } = require('@date-fns/tz');
10
-
11
- // Dynamically find an invalid 3-letter IATA
12
- function getInvalidIata(): string {
13
- const existing = new Set(Object.keys(timezones));
14
- for (let a = 65; a <= 90; a++) {
15
- for (let b = 65; b <= 90; b++) {
16
- for (let c = 65; c <= 90; c++) {
17
- const code = String.fromCharCode(a, b, c);
18
- if (!existing.has(code)) return code;
19
- }
20
- }
21
- }
22
- throw new Error('All codes taken?!');
23
- }
3
+ import { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from '../src/errors';
4
+ import { getInvalidIata } from './helpers';
5
+
24
6
  const invalidIata = getInvalidIata();
25
7
 
26
8
  describe('convertToUTC', () => {
27
9
  it('converts JFK local time (UTC–4 in May) correctly', () => {
28
- expect(convertToUTC('2025-05-02T14:30', 'JFK'))
29
- .toBe('2025-05-02T18:30:00Z');
10
+ expect(convertToUTC('2025-05-02T14:30', 'JFK')).toBe('2025-05-02T18:30:00Z');
30
11
  });
31
12
 
32
13
  it('throws UnknownAirportError for bad IATA', () => {
33
- expect(() => convertToUTC('2025-05-02T14:30', invalidIata))
34
- .toThrow(UnknownAirportError);
14
+ expect(() => convertToUTC('2025-05-02T14:30', invalidIata)).toThrow(UnknownAirportError);
35
15
  });
36
16
 
37
17
  it('throws InvalidTimestampError for malformed timestamp', () => {
38
- expect(() => convertToUTC('not-a-timestamp', 'JFK'))
39
- .toThrow(InvalidTimestampError);
18
+ expect(() => convertToUTC('not-a-timestamp', 'JFK')).toThrow(InvalidTimestampError);
19
+ });
20
+
21
+ it('throws InvalidTimestampError for invalid date components', () => {
22
+ expect(() => convertToUTC('2025-13-02T14:30', 'JFK')).toThrow(InvalidTimestampError);
23
+ expect(() => convertToUTC('2025-02-30T14:30', 'JFK')).toThrow(InvalidTimestampError);
24
+ expect(() => convertToUTC('2025-04-31T14:30', 'JFK')).toThrow(InvalidTimestampError);
25
+ expect(() => convertToUTC('2025-05-02T25:30', 'JFK')).toThrow(InvalidTimestampError);
26
+ expect(() => convertToUTC('2025-05-02T23:60', 'JFK')).toThrow(InvalidTimestampError);
27
+ expect(() => convertToUTC('2025-05-02T23:59:60', 'JFK')).toThrow(InvalidTimestampError);
40
28
  });
41
29
 
42
30
  it('throws InvalidTimestampError for fractional seconds format', () => {
43
- expect(() => convertToUTC('2025-05-02T14:30:00.123', 'JFK'))
44
- .toThrow(InvalidTimestampError);
31
+ expect(() => convertToUTC('2025-05-02T14:30:00.123', 'JFK')).toThrow(InvalidTimestampError);
45
32
  });
46
33
 
47
34
  describe('error branches', () => {
48
- const original = TZDate.tz;
49
- afterEach(() => { TZDate.tz = original; });
50
-
51
35
  it('throws InvalidTimestampError if TZDate.tz throws', () => {
52
- TZDate.tz = () => { throw new Error('forced'); };
53
- expect(() => convertToUTC('2025-05-02T14:30', 'JFK'))
54
- .toThrow(InvalidTimestampError);
36
+ const spy = jest.spyOn(TZDate, 'tz').mockImplementation(() => {
37
+ throw new Error('forced');
38
+ });
39
+ expect(() => convertToUTC('2025-05-02T14:30', 'JFK')).toThrow(InvalidTimestampError);
40
+ spy.mockRestore();
55
41
  });
56
42
 
57
43
  it('throws InvalidTimestampError if TZDate.tz returns invalid Date', () => {
58
- TZDate.tz = () => {
59
- const d = new Date(NaN);
44
+ const spy = jest.spyOn(TZDate, 'tz').mockImplementation(() => {
45
+ const d = new Date(NaN) as unknown as TZDate;
60
46
  Object.setPrototypeOf(d, TZDate.prototype);
61
47
  return d;
62
- };
63
- expect(() => convertToUTC('2025-05-02T14:30', 'JFK'))
64
- .toThrow(InvalidTimestampError);
48
+ });
49
+ expect(() => convertToUTC('2025-05-02T14:30', 'JFK')).toThrow(InvalidTimestampError);
50
+ spy.mockRestore();
65
51
  });
66
52
  });
67
53
  });
68
54
 
69
55
  describe('convertLocalToUTCByZone', () => {
70
56
  it('converts London local time to UTC', () => {
71
- expect(convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London'))
72
- .toBe('2025-05-02T13:30:00Z');
57
+ expect(convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London')).toBe(
58
+ '2025-05-02T13:30:00Z'
59
+ );
60
+ });
61
+
62
+ it('accepts leap day dates', () => {
63
+ expect(convertLocalToUTCByZone('2024-02-29T12:00:00', 'Europe/London')).toBe(
64
+ '2024-02-29T12:00:00Z'
65
+ );
73
66
  });
74
67
 
75
68
  it('throws UnknownTimezoneError for invalid tz', () => {
76
- expect(() =>
77
- convertLocalToUTCByZone('2025-05-02T14:30:00', 'Not/A_Zone')
78
- ).toThrow(UnknownTimezoneError);
69
+ expect(() => convertLocalToUTCByZone('2025-05-02T14:30:00', 'Not/A_Zone')).toThrow(
70
+ UnknownTimezoneError
71
+ );
72
+ });
73
+
74
+ it('caches invalid timezone results', () => {
75
+ expect(() => convertLocalToUTCByZone('2025-05-02T14:30:00', 'Not/A_Zone')).toThrow(
76
+ UnknownTimezoneError
77
+ );
78
+ expect(() => convertLocalToUTCByZone('2025-05-02T14:30:00', 'Not/A_Zone')).toThrow(
79
+ UnknownTimezoneError
80
+ );
79
81
  });
80
82
 
81
83
  it('throws InvalidTimestampError for malformed timestamp', () => {
82
- expect(() =>
83
- convertLocalToUTCByZone('bad-format', 'Europe/London')
84
- ).toThrow(InvalidTimestampError);
84
+ expect(() => convertLocalToUTCByZone('bad-format', 'Europe/London')).toThrow(
85
+ InvalidTimestampError
86
+ );
87
+ });
88
+
89
+ it('throws InvalidTimestampError for invalid date components', () => {
90
+ expect(() => convertLocalToUTCByZone('2025-13-02T14:30:00', 'Europe/London')).toThrow(
91
+ InvalidTimestampError
92
+ );
93
+ expect(() => convertLocalToUTCByZone('2025-02-30T14:30:00', 'Europe/London')).toThrow(
94
+ InvalidTimestampError
95
+ );
96
+ expect(() => convertLocalToUTCByZone('2025-05-02T24:30:00', 'Europe/London')).toThrow(
97
+ InvalidTimestampError
98
+ );
85
99
  });
86
100
 
87
101
  it('throws InvalidTimestampError for fractional seconds format', () => {
88
- expect(() =>
89
- convertLocalToUTCByZone('2025-05-02T14:30:00.123', 'Europe/London')
90
- ).toThrow(InvalidTimestampError);
102
+ expect(() => convertLocalToUTCByZone('2025-05-02T14:30:00.123', 'Europe/London')).toThrow(
103
+ InvalidTimestampError
104
+ );
91
105
  });
92
106
 
93
107
  describe('error branches', () => {
94
- const original = TZDate.tz;
95
- afterEach(() => { TZDate.tz = original; });
96
-
97
108
  it('throws UnknownTimezoneError if TZDate.tz throws for valid zone', () => {
98
- TZDate.tz = () => { throw new RangeError('forced'); };
99
- expect(() =>
100
- convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London')
101
- ).toThrow(UnknownTimezoneError);
109
+ const spy = jest.spyOn(TZDate, 'tz').mockImplementation(() => {
110
+ throw new RangeError('forced');
111
+ });
112
+ expect(() => convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London')).toThrow(
113
+ UnknownTimezoneError
114
+ );
115
+ spy.mockRestore();
102
116
  });
103
117
 
104
118
  it('throws InvalidTimestampError if TZDate.tz returns invalid Date', () => {
105
- TZDate.tz = () => {
106
- const d = new Date(NaN);
119
+ const spy = jest.spyOn(TZDate, 'tz').mockImplementation(() => {
120
+ const d = new Date(NaN) as unknown as TZDate;
107
121
  Object.setPrototypeOf(d, TZDate.prototype);
108
122
  return d;
109
- };
110
- expect(() =>
111
- convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London')
112
- ).toThrow(InvalidTimestampError);
123
+ });
124
+ expect(() => convertLocalToUTCByZone('2025-05-02T14:30:00', 'Europe/London')).toThrow(
125
+ InvalidTimestampError
126
+ );
127
+ spy.mockRestore();
113
128
  });
114
129
  });
115
130
  });
@@ -0,0 +1,233 @@
1
+ const buildCsv = (rows: string[]) => {
2
+ const header = [
3
+ 'iata_code',
4
+ 'timezone',
5
+ 'latitude',
6
+ 'longitude',
7
+ 'name',
8
+ 'city_name_list',
9
+ 'location_type',
10
+ 'country_code',
11
+ 'country_name',
12
+ 'continent_name'
13
+ ].join('^');
14
+ return [header, ...rows].join('\n');
15
+ };
16
+
17
+ const globalAny = globalThis as unknown as { fetch?: typeof globalThis.fetch };
18
+
19
+ jest.mock('fs', () => {
20
+ const realFs = jest.requireActual('fs');
21
+ return {
22
+ __esModule: true,
23
+ default: {
24
+ ...realFs,
25
+ mkdirSync: jest.fn(),
26
+ writeFileSync: jest.fn()
27
+ }
28
+ };
29
+ });
30
+
31
+ jest.mock('prettier', () => ({
32
+ __esModule: true,
33
+ default: {
34
+ resolveConfig: jest.fn(),
35
+ format: jest.fn(async (text: string) => text)
36
+ }
37
+ }));
38
+
39
+ describe('generateMapping', () => {
40
+ beforeEach(() => {
41
+ jest.resetModules();
42
+ jest.clearAllMocks();
43
+ });
44
+
45
+ it('writes mappings and normalizes city names', async () => {
46
+ const csv = buildCsv([
47
+ // Airport row should win and city should be last token after "="
48
+ 'ACE^Atlantic/Canary^28.95027^-13.60556^Lanzarote Airport^Lanzarote=Arrecife^A^ES^Spain^Europe',
49
+ // City rows should be ignored
50
+ 'ACE^Europe/Paris^28.96302^-13.54769^Arrecife^Lanzarote=Arrecife^C^ES^Spain^Europe',
51
+ 'AMS^Europe/Amsterdam^52.37403^4.88969^Amsterdam^Amsterdam=Schiphol^C^NL^Netherlands^Europe',
52
+ 'AMS^Europe/Amsterdam^52.3103^4.76028^Amsterdam Airport Schiphol^Amsterdam=Schiphol^A^NL^Netherlands^Europe',
53
+ // Invalid code should be skipped
54
+ 'ZZ^UTC^0^0^Bad^Bad City^A^ZZ^Nowhere^Antarctica',
55
+ // Missing timezone should not add to timezone map
56
+ 'NOT^^10^20^No Tz Airport^Foo,Bar^A^XX^Nowhere^Asia',
57
+ // Invalid lat/lon should be skipped for geo
58
+ 'INV^UTC^^20^Invalid^Foo^A^XX^Nowhere^Asia',
59
+ // Empty city list should fall back to empty city
60
+ 'EMP^UTC^1^2^Empty City^^A^XX^Nowhere^Asia',
61
+ // Short row should yield undefined location_type
62
+ 'MIS^UTC^1^2^Missing Fields^City'
63
+ ]);
64
+
65
+ const fetchMock = jest.fn(async () => ({
66
+ ok: true,
67
+ text: async () => csv
68
+ })) as unknown as typeof globalThis.fetch;
69
+ globalAny.fetch = fetchMock;
70
+
71
+ const fs = await import('fs');
72
+ const prettier = await import('prettier');
73
+ (prettier.default.resolveConfig as jest.Mock).mockResolvedValue({});
74
+
75
+ const { generateMapping } = await import('../scripts/generateMapping');
76
+ await generateMapping();
77
+
78
+ expect(fs.default.mkdirSync as jest.Mock).toHaveBeenCalled();
79
+ expect(fs.default.writeFileSync as jest.Mock).toHaveBeenCalledTimes(2);
80
+ expect(prettier.default.resolveConfig).toHaveBeenCalledWith(expect.any(String), {
81
+ editorconfig: true
82
+ });
83
+ expect(prettier.default.format).toHaveBeenCalledWith(
84
+ expect.any(String),
85
+ expect.objectContaining({ parser: 'typescript' })
86
+ );
87
+
88
+ const geoWrite = (fs.default.writeFileSync as jest.Mock).mock.calls.find(([file]) =>
89
+ String(file).endsWith('geo.ts')
90
+ );
91
+ expect(geoWrite).toBeTruthy();
92
+ const geoContents = String(geoWrite?.[1]);
93
+ expect(geoContents).toContain('"city": "Arrecife"');
94
+ expect(geoContents).toContain('"city": "Schiphol"');
95
+ expect(geoContents).not.toContain('Warszawa Centralna Railway Station');
96
+ });
97
+
98
+ it('throws when required columns are missing', async () => {
99
+ const badCsv = ['iata_code^timezone^latitude'].join('\n');
100
+
101
+ const fetchMock = jest.fn(async () => ({
102
+ ok: true,
103
+ text: async () => badCsv
104
+ })) as unknown as typeof globalThis.fetch;
105
+ globalAny.fetch = fetchMock;
106
+
107
+ const prettier = await import('prettier');
108
+ (prettier.default.resolveConfig as jest.Mock).mockResolvedValue({});
109
+
110
+ const { generateMapping } = await import('../scripts/generateMapping');
111
+ await expect(generateMapping()).rejects.toThrow('Missing required OPTD columns');
112
+ });
113
+
114
+ it('uses global fetch when fetchImpl is not provided', async () => {
115
+ const csv = buildCsv([
116
+ 'ACE^Atlantic/Canary^28.95027^-13.60556^Lanzarote Airport^Lanzarote=Arrecife^A^ES^Spain^Europe'
117
+ ]);
118
+ const globalFetch = jest.fn(async () => ({
119
+ ok: true,
120
+ text: async () => csv
121
+ })) as unknown as typeof globalThis.fetch;
122
+ const previousFetch = globalAny.fetch;
123
+ globalAny.fetch = globalFetch;
124
+
125
+ const prettier = await import('prettier');
126
+ (prettier.default.resolveConfig as jest.Mock).mockResolvedValue({});
127
+
128
+ const { generateMapping } = await import('../scripts/generateMapping');
129
+ await generateMapping();
130
+
131
+ expect(globalFetch).toHaveBeenCalled();
132
+ globalAny.fetch = previousFetch;
133
+ });
134
+
135
+ it('throws when global fetch is missing', async () => {
136
+ const previousFetch = globalAny.fetch;
137
+ delete globalAny.fetch;
138
+
139
+ const { generateMapping } = await import('../scripts/generateMapping');
140
+ await expect(generateMapping()).rejects.toThrow('Global fetch is not available.');
141
+
142
+ globalAny.fetch = previousFetch;
143
+ });
144
+
145
+ it('throws when fetch response is not ok', async () => {
146
+ const fetchMock = jest.fn(async () => ({
147
+ ok: false,
148
+ statusText: 'Bad Gateway',
149
+ text: async () => ''
150
+ })) as unknown as typeof globalThis.fetch;
151
+ globalAny.fetch = fetchMock;
152
+
153
+ const prettier = await import('prettier');
154
+ (prettier.default.resolveConfig as jest.Mock).mockResolvedValue({});
155
+
156
+ const { generateMapping } = await import('../scripts/generateMapping');
157
+ await expect(generateMapping()).rejects.toThrow('Fetch failed: Bad Gateway');
158
+ });
159
+
160
+ it('uses fallback config and unknown error status text', async () => {
161
+ const fetchMock = jest.fn(async () => ({
162
+ ok: false,
163
+ text: async () => ''
164
+ })) as unknown as typeof globalThis.fetch;
165
+ globalAny.fetch = fetchMock;
166
+
167
+ const prettier = await import('prettier');
168
+ (prettier.default.resolveConfig as jest.Mock).mockResolvedValue(null);
169
+
170
+ const { generateMapping } = await import('../scripts/generateMapping');
171
+ await expect(generateMapping()).rejects.toThrow('Fetch failed: Unknown error');
172
+ });
173
+
174
+ it('skips rows that are not airports', async () => {
175
+ const csv = buildCsv([
176
+ 'CIT^UTC^1^2^City^City^C^XX^Nowhere^Asia',
177
+ 'BUS^UTC^3^4^Bus Station^City^B^XX^Nowhere^Asia',
178
+ 'AIR^UTC^5^6^Airport^City^A^XX^Nowhere^Asia',
179
+ 'NTZ^^7^8^No Tz Airport^City^A^XX^Nowhere^Asia',
180
+ 'NTZ^UTC^7^8^No Tz Airport^City^A^XX^Nowhere^Asia'
181
+ ]);
182
+ const fetchMock = jest.fn(async () => ({
183
+ ok: true,
184
+ text: async () => csv
185
+ })) as unknown as typeof globalThis.fetch;
186
+ globalAny.fetch = fetchMock;
187
+
188
+ const fs = await import('fs');
189
+ const prettier = await import('prettier');
190
+ (prettier.default.resolveConfig as jest.Mock).mockResolvedValue({});
191
+
192
+ const { generateMapping } = await import('../scripts/generateMapping');
193
+ await generateMapping();
194
+
195
+ const geoWrite = (fs.default.writeFileSync as jest.Mock).mock.calls.find(([file]) =>
196
+ String(file).endsWith('geo.ts')
197
+ );
198
+ expect(geoWrite).toBeTruthy();
199
+ const geoContents = String(geoWrite?.[1]);
200
+ expect(geoContents).toContain('"AIR"');
201
+ expect(geoContents).toContain('"NTZ"');
202
+ expect(geoContents).not.toContain('"CIT"');
203
+ expect(geoContents).not.toContain('"BUS"');
204
+ });
205
+
206
+ it('uses default cwd/sourceUrl when omitted', async () => {
207
+ const csv = buildCsv(['DEF^UTC^1^2^Default Airport^Default City^A^XX^Nowhere^Asia']);
208
+ const fetchMock = jest.fn(async () => ({
209
+ ok: true,
210
+ text: async () => csv
211
+ })) as unknown as typeof globalThis.fetch;
212
+ globalAny.fetch = fetchMock;
213
+
214
+ const prettier = await import('prettier');
215
+ (prettier.default.resolveConfig as jest.Mock).mockResolvedValue({});
216
+
217
+ const fs = await import('fs');
218
+ const os = await import('os');
219
+ const path = await import('path');
220
+ const previousCwd = process.cwd();
221
+ const tempDir = fs.default.mkdtempSync(path.join(os.tmpdir(), 'airport-utils-'));
222
+ process.chdir(tempDir);
223
+
224
+ const { generateMapping } = await import('../scripts/generateMapping');
225
+ await generateMapping();
226
+
227
+ expect(fetchMock).toHaveBeenCalledWith(
228
+ 'https://raw.githubusercontent.com/opentraveldata/opentraveldata/master/opentraveldata/optd_por_public.csv'
229
+ );
230
+
231
+ process.chdir(previousCwd);
232
+ });
233
+ });
@@ -0,0 +1,16 @@
1
+ import { timezones } from '../src/mapping/timezones';
2
+
3
+ // Dynamically find an invalid 3-letter IATA code not in the mapping.
4
+ export function getInvalidIata(): string {
5
+ const existing = new Set(Object.keys(timezones));
6
+ for (let a = 65; a <= 90; a++) {
7
+ for (let b = 65; b <= 90; b++) {
8
+ for (let c = 65; c <= 90; c++) {
9
+ const code = String.fromCharCode(a, b, c);
10
+ if (!existing.has(code)) return code;
11
+ }
12
+ }
13
+ }
14
+ /* istanbul ignore next */
15
+ throw new Error('All codes taken?!');
16
+ }
@@ -0,0 +1,36 @@
1
+ describe('getAllAirports with inconsistent mappings', () => {
2
+ it('excludes airports missing timezone entries', async () => {
3
+ jest.resetModules();
4
+ jest.doMock('../src/mapping/timezones', () => ({
5
+ timezones: { AAA: 'UTC' }
6
+ }));
7
+ jest.doMock('../src/mapping/geo', () => ({
8
+ geo: {
9
+ AAA: {
10
+ latitude: 1,
11
+ longitude: 2,
12
+ name: 'Airport A',
13
+ city: 'City A',
14
+ country: 'AA',
15
+ countryName: 'Aland',
16
+ continent: 'Europe'
17
+ },
18
+ BBB: {
19
+ latitude: 3,
20
+ longitude: 4,
21
+ name: 'Airport B',
22
+ city: 'City B',
23
+ country: 'BB',
24
+ countryName: 'Bland',
25
+ continent: 'Asia'
26
+ }
27
+ }
28
+ }));
29
+
30
+ const { getAllAirports } = await import('../src/info');
31
+ const all = getAllAirports();
32
+
33
+ expect(all).toHaveLength(1);
34
+ expect(all[0].iata).toBe('AAA');
35
+ });
36
+ });
@@ -1,26 +1,13 @@
1
- import { getAirportInfo } from '../src/info';
1
+ import { getAirportInfo, getAllAirports } from '../src/info';
2
2
  import { UnknownAirportError } from '../src/errors';
3
3
  import { timezones } from '../src/mapping/timezones';
4
4
  import { geo } from '../src/mapping/geo';
5
-
6
- // Dynamically find the first 3-letter code not in our mapping
7
- function getInvalidIata(): string {
8
- const existing = new Set(Object.keys(timezones));
9
- for (let a = 65; a <= 90; a++) {
10
- for (let b = 65; b <= 90; b++) {
11
- for (let c = 65; c <= 90; c++) {
12
- const code = String.fromCharCode(a, b, c);
13
- if (!existing.has(code)) return code;
14
- }
15
- }
16
- }
17
- throw new Error('All 3-letter codes are taken?!');
18
- }
5
+ import { getInvalidIata } from './helpers';
19
6
 
20
7
  const invalidIata = getInvalidIata();
21
8
 
22
9
  describe('getAirportInfo', () => {
23
- const validCodes = Object.keys(timezones).filter(i => geo[i]);
10
+ const validCodes = Object.keys(timezones).filter((i) => geo[i]);
24
11
  const sample = validCodes.length > 0 ? validCodes[0] : 'JFK';
25
12
 
26
13
  it('returns full info for a valid IATA', () => {
@@ -30,10 +17,10 @@ describe('getAirportInfo', () => {
30
17
  ...geo[sample]
31
18
  });
32
19
  expect(info.continent).toBeDefined();
20
+ expect(info.countryName).toBeDefined();
33
21
  });
34
22
 
35
23
  it('returns all airports', () => {
36
- const { getAllAirports } = require('../src/info');
37
24
  const all = getAllAirports();
38
25
  expect(all.length).toBeGreaterThan(0);
39
26
  expect(all[0]).toHaveProperty('iata');
@@ -41,7 +28,6 @@ describe('getAirportInfo', () => {
41
28
  });
42
29
 
43
30
  it('throws UnknownAirportError for missing IATA', () => {
44
- expect(() => getAirportInfo(invalidIata))
45
- .toThrow(UnknownAirportError);
31
+ expect(() => getAirportInfo(invalidIata)).toThrow(UnknownAirportError);
46
32
  });
47
33
  });