Skip to content

Commit 7dbbd2a

Browse files
committed
fix: require persistent opensky emergency squawks
1 parent fecb2af commit 7dbbd2a

2 files changed

Lines changed: 193 additions & 135 deletions

File tree

src/modules/ingest/app/sources/opensky-emergency-squawk.source.test.ts

Lines changed: 124 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ describe('OpenSkyEmergencySquawkSource', () => {
99
vi.unstubAllGlobals();
1010
});
1111

12-
it('should fetch token and emit emergency squawk events', async () => {
12+
it('should emit emergency squawk events only after the code persists for 10 minutes', async () => {
1313
vi.useFakeTimers();
1414
const now = new Date('2026-02-05T00:00:00.000Z');
1515
vi.setSystemTime(now);
@@ -19,88 +19,45 @@ describe('OpenSkyEmergencySquawkSource', () => {
1919
vi.resetModules();
2020
const { OpenSkyEmergencySquawkSource } = await import('./opensky-emergency-squawk.source');
2121

22-
const responseTime = Math.floor(now.getTime() / 1000);
23-
const states = [
24-
[
25-
'769104',
26-
'SIA7436 ',
27-
'Singapore',
28-
responseTime,
29-
responseTime,
30-
126.9869,
31-
36.2345,
32-
6248.4,
33-
false,
34-
196.74,
35-
4.95,
36-
-7.15,
37-
null,
38-
6256.02,
39-
'7700',
40-
false,
41-
0,
42-
2,
43-
],
44-
[
45-
'782160',
46-
'CSN652 ',
47-
'China',
48-
responseTime,
49-
responseTime,
50-
125.2541,
51-
37.3973,
52-
7802.88,
53-
false,
54-
192.46,
55-
268.62,
56-
0,
57-
null,
58-
7757.16,
59-
'1200',
60-
false,
61-
0,
62-
2,
63-
],
64-
];
65-
66-
const fetchMock = vi.fn().mockImplementation((url: RequestInfo) => {
67-
const urlText = String(url);
68-
if (urlText.includes('openid-connect/token')) {
69-
return Promise.resolve(
70-
new Response(JSON.stringify({ access_token: 'token-1', expires_in: 1800 }), { status: 200 }),
71-
);
72-
}
73-
if (urlText.includes('/api/states/all')) {
74-
return Promise.resolve(new Response(JSON.stringify({ time: responseTime, states }), { status: 200 }));
75-
}
76-
return Promise.resolve(new Response('', { status: 404 }));
22+
const currentSquawk = '7700';
23+
const fetchMock = createOpenSkyFetchMock({
24+
resolveStates() {
25+
return [buildOpenSkyState(currentSquawk)];
26+
},
7727
});
7828
vi.stubGlobal('fetch', fetchMock);
7929

8030
const source = new OpenSkyEmergencySquawkSource();
81-
const result = await source.run(null);
8231

83-
expect(fetchMock).toHaveBeenCalledTimes(2);
84-
const stateCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes('/api/states/all'));
85-
const authHeader = new Headers(stateCalls[0]?.[1]?.headers as HeadersInit).get('Authorization');
86-
expect(authHeader).toBe('Bearer token-1');
32+
const firstRun = await source.run(null);
33+
expect(firstRun.events).toHaveLength(0);
8734

88-
expect(result.events).toHaveLength(1);
89-
expect(result.events[0].title).toBe('한국 상공 SIA7436편 7700(비상사태) 선언');
90-
expect(result.events[0].body).toBe(
35+
vi.setSystemTime(new Date(now.getTime() + 9 * 60 * 1000));
36+
const secondRun = await source.run(firstRun.nextState);
37+
expect(secondRun.events).toHaveLength(0);
38+
39+
vi.setSystemTime(new Date(now.getTime() + 10 * 60 * 1000));
40+
const thirdRun = await source.run(secondRun.nextState);
41+
expect(thirdRun.events).toHaveLength(1);
42+
expect(thirdRun.events[0].title).toBe('한국 상공 SIA7436편 7700(비상사태) 선언');
43+
expect(thirdRun.events[0].body).toBe(
9144
[
9245
'국적: Singapore',
9346
'고도: 기압고도 6248m / 지오고도 6256m (공중)',
9447
'비행: 속도 197 m/s, 진로 5°, 상승률 -7.1 m/s',
9548
].join('\n'),
9649
);
97-
expect(result.events[0].level).toBe(EventLevels.Moderate);
98-
const payload = result.events[0].payload as { squawk?: string };
50+
expect(thirdRun.events[0].level).toBe(EventLevels.Moderate);
51+
const payload = thirdRun.events[0].payload as { squawk?: string };
9952
expect(payload.squawk).toBe('7700');
100-
expect(result.nextState).not.toContain('token-1');
53+
expect(thirdRun.nextState).not.toContain('token-1');
54+
55+
vi.setSystemTime(new Date(now.getTime() + 15 * 60 * 1000));
56+
const fourthRun = await source.run(thirdRun.nextState);
57+
expect(fourthRun.events).toHaveLength(0);
10158
});
10259

103-
it('should refresh token on unauthorized response', async () => {
60+
it('should reset pending state when squawk changes before confirmation', async () => {
10461
vi.useFakeTimers();
10562
const now = new Date('2026-02-05T00:00:00.000Z');
10663
vi.setSystemTime(now);
@@ -110,29 +67,44 @@ describe('OpenSkyEmergencySquawkSource', () => {
11067
vi.resetModules();
11168
const { OpenSkyEmergencySquawkSource } = await import('./opensky-emergency-squawk.source');
11269

113-
const responseTime = Math.floor(now.getTime() / 1000);
114-
const states = [
115-
[
116-
'769104',
117-
'SIA7436 ',
118-
'Singapore',
119-
responseTime,
120-
responseTime,
121-
126.9869,
122-
36.2345,
123-
6248.4,
124-
false,
125-
196.74,
126-
4.95,
127-
-7.15,
128-
null,
129-
6256.02,
130-
'7500',
131-
false,
132-
0,
133-
2,
134-
],
135-
];
70+
let currentSquawk = '7700';
71+
const fetchMock = createOpenSkyFetchMock({
72+
resolveStates() {
73+
return [buildOpenSkyState(currentSquawk)];
74+
},
75+
});
76+
vi.stubGlobal('fetch', fetchMock);
77+
78+
const source = new OpenSkyEmergencySquawkSource();
79+
80+
const firstRun = await source.run(null);
81+
expect(firstRun.events).toHaveLength(0);
82+
83+
vi.setSystemTime(new Date(now.getTime() + 4 * 60 * 1000));
84+
currentSquawk = '7600';
85+
const secondRun = await source.run(firstRun.nextState);
86+
expect(secondRun.events).toHaveLength(0);
87+
88+
vi.setSystemTime(new Date(now.getTime() + 13 * 60 * 1000));
89+
const thirdRun = await source.run(secondRun.nextState);
90+
expect(thirdRun.events).toHaveLength(0);
91+
92+
vi.setSystemTime(new Date(now.getTime() + 14 * 60 * 1000));
93+
const fourthRun = await source.run(thirdRun.nextState);
94+
expect(fourthRun.events).toHaveLength(1);
95+
expect(fourthRun.events[0].title).toBe('한국 상공 SIA7436편 7600(통신장애) 선언');
96+
expect(fourthRun.events[0].level).toBe(EventLevels.Minor);
97+
});
98+
99+
it('should refresh token on unauthorized response', async () => {
100+
vi.useFakeTimers();
101+
const now = new Date('2026-02-05T00:00:00.000Z');
102+
vi.setSystemTime(now);
103+
104+
process.env.OPENSKY_CLIENT_ID = 'test-client';
105+
process.env.OPENSKY_CLIENT_SECRET = 'test-secret';
106+
vi.resetModules();
107+
const { OpenSkyEmergencySquawkSource } = await import('./opensky-emergency-squawk.source');
136108

137109
let tokenCalls = 0;
138110
let stateCalls = 0;
@@ -149,14 +121,29 @@ describe('OpenSkyEmergencySquawkSource', () => {
149121
if (stateCalls === 1) {
150122
return Promise.resolve(new Response('', { status: 401 }));
151123
}
152-
return Promise.resolve(new Response(JSON.stringify({ time: responseTime, states }), { status: 200 }));
124+
const responseTime = Math.floor(Date.now() / 1000);
125+
return Promise.resolve(
126+
new Response(JSON.stringify({ time: responseTime, states: [buildOpenSkyState('7500', responseTime)] }), {
127+
status: 200,
128+
}),
129+
);
153130
}
154131
return Promise.resolve(new Response('', { status: 404 }));
155132
});
156133
vi.stubGlobal('fetch', fetchMock);
157134

158135
const source = new OpenSkyEmergencySquawkSource();
159-
const result = await source.run(null);
136+
const checkpointState = JSON.stringify({
137+
tracked: {
138+
'769104': {
139+
squawk: '7500',
140+
firstObservedAt: new Date(now.getTime() - 10 * 60 * 1000).toISOString(),
141+
emittedAt: null,
142+
},
143+
},
144+
});
145+
146+
const result = await source.run(checkpointState);
160147

161148
expect(tokenCalls).toBe(2);
162149
expect(stateCalls).toBe(2);
@@ -177,3 +164,47 @@ describe('OpenSkyEmergencySquawkSource', () => {
177164
expect(result.nextState).not.toContain('token-2');
178165
});
179166
});
167+
168+
function createOpenSkyFetchMock(options: { resolveStates: () => unknown[][] }): ReturnType<typeof vi.fn> {
169+
let tokenCalls = 0;
170+
171+
return vi.fn().mockImplementation((url: RequestInfo) => {
172+
const urlText = String(url);
173+
if (urlText.includes('openid-connect/token')) {
174+
tokenCalls += 1;
175+
return Promise.resolve(
176+
new Response(JSON.stringify({ access_token: `token-${tokenCalls}`, expires_in: 1800 }), { status: 200 }),
177+
);
178+
}
179+
if (urlText.includes('/api/states/all')) {
180+
const responseTime = Math.floor(Date.now() / 1000);
181+
return Promise.resolve(
182+
new Response(JSON.stringify({ time: responseTime, states: options.resolveStates() }), { status: 200 }),
183+
);
184+
}
185+
return Promise.resolve(new Response('', { status: 404 }));
186+
});
187+
}
188+
189+
function buildOpenSkyState(squawk: string, responseTime = Math.floor(Date.now() / 1000)): unknown[] {
190+
return [
191+
'769104',
192+
'SIA7436 ',
193+
'Singapore',
194+
responseTime,
195+
responseTime,
196+
126.9869,
197+
36.2345,
198+
6248.4,
199+
false,
200+
196.74,
201+
4.95,
202+
-7.15,
203+
null,
204+
6256.02,
205+
squawk,
206+
false,
207+
0,
208+
2,
209+
];
210+
}

0 commit comments

Comments
 (0)