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.
- package/.editorconfig +12 -0
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +7 -1
- package/.github/workflows/update-mapping.yml +1 -1
- package/.prettierrc.json +6 -0
- package/README.md +4 -1
- package/dist/cjs/converter.js +58 -45
- package/dist/cjs/info.js +7 -3
- package/dist/cjs/mapping/geo.js +75681 -12015
- package/dist/cjs/mapping/timezones.js +8409 -12028
- package/dist/esm/converter.js +58 -45
- package/dist/esm/info.js +7 -3
- package/dist/esm/mapping/geo.js +75681 -12015
- package/dist/esm/mapping/timezones.js +8409 -12028
- package/dist/types/info.d.ts +1 -0
- package/dist/types/mapping/geo.d.ts +1 -0
- package/eslint.config.cjs +84 -0
- package/jest.config.js +5 -1
- package/package.json +15 -8
- package/scripts/generateMapping.ts +72 -39
- package/src/converter.ts +64 -57
- package/src/errors.ts +1 -1
- package/src/index.ts +1 -1
- package/src/info.ts +9 -5
- package/src/mapping/geo.ts +75682 -12015
- package/src/mapping/timezones.ts +8410 -12030
- package/tests/built.test.ts +59 -42
- package/tests/converter.test.ts +80 -65
- package/tests/generateMapping.test.ts +233 -0
- package/tests/helpers.ts +16 -0
- package/tests/info.mocks.test.ts +36 -0
- package/tests/info.test.ts +5 -19
package/tests/built.test.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
});
|
package/tests/converter.test.ts
CHANGED
|
@@ -1,115 +1,130 @@
|
|
|
1
|
+
import { TZDate } from '@date-fns/tz';
|
|
1
2
|
import { convertToUTC, convertLocalToUTCByZone } from '../src/converter';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
)
|
|
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
|
-
|
|
84
|
-
)
|
|
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
|
-
|
|
90
|
-
)
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
).toThrow(
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
)
|
|
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
|
+
});
|
package/tests/helpers.ts
ADDED
|
@@ -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
|
+
});
|
package/tests/info.test.ts
CHANGED
|
@@ -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
|
});
|