Skip to content

Commit 71e53ac

Browse files
committed
refactor: split fetch util
1 parent a47a1a4 commit 71e53ac

4 files changed

Lines changed: 212 additions & 33 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
4+
import { fetchBanners } from '../fetchBanners.mjs';
5+
6+
const PAST = new Date(Date.now() - 86_400_000).toISOString(); // yesterday
7+
const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); // tomorrow
8+
9+
const makeResponse = (banners, ok = true) => ({
10+
ok,
11+
json: async () => ({ websiteBanners: banners }),
12+
});
13+
14+
describe('fetchBanners', () => {
15+
describe('fetch behavior', () => {
16+
it('fetches from the given URL', async t => {
17+
t.mock.method(global, 'fetch', () => Promise.resolve(makeResponse({})));
18+
19+
await fetchBanners('https://example.com/site.json', null);
20+
21+
assert.equal(global.fetch.mock.calls.length, 1);
22+
assert.equal(
23+
global.fetch.mock.calls[0].arguments[0],
24+
'https://example.com/site.json'
25+
);
26+
});
27+
28+
it('returns an empty array on non-ok response', async t => {
29+
t.mock.method(global, 'fetch', () =>
30+
Promise.resolve(makeResponse({}, false))
31+
);
32+
33+
const result = await fetchBanners('https://example.com/site.json', null);
34+
35+
assert.deepEqual(result, []);
36+
});
37+
38+
it('propagates fetch errors to the caller', async t => {
39+
t.mock.method(global, 'fetch', () =>
40+
Promise.reject(new Error('Network error'))
41+
);
42+
43+
await assert.rejects(
44+
() => fetchBanners('https://example.com/site.json', null),
45+
{ message: 'Network error' }
46+
);
47+
});
48+
});
49+
50+
describe('banner selection', () => {
51+
it('returns the active global (index) banner', async t => {
52+
const banner = { text: 'Global banner', type: 'warning' };
53+
t.mock.method(global, 'fetch', () =>
54+
Promise.resolve(makeResponse({ index: banner }))
55+
);
56+
57+
const result = await fetchBanners('https://example.com/site.json', null);
58+
59+
assert.deepEqual(result, [banner]);
60+
});
61+
62+
it('returns the active version-specific banner', async t => {
63+
const banner = { text: 'v20 banner', type: 'warning' };
64+
t.mock.method(global, 'fetch', () =>
65+
Promise.resolve(makeResponse({ v20: banner }))
66+
);
67+
68+
const result = await fetchBanners('https://example.com/site.json', 20);
69+
70+
assert.deepEqual(result, [banner]);
71+
});
72+
73+
it('returns both global and version banners when both are active', async t => {
74+
const globalBanner = { text: 'Global banner', type: 'warning' };
75+
const versionBanner = { text: 'v20 banner', type: 'error' };
76+
t.mock.method(global, 'fetch', () =>
77+
Promise.resolve(
78+
makeResponse({ index: globalBanner, v20: versionBanner })
79+
)
80+
);
81+
82+
const result = await fetchBanners('https://example.com/site.json', 20);
83+
84+
assert.deepEqual(result, [globalBanner, versionBanner]);
85+
});
86+
87+
it('returns global banner first, version banner second', async t => {
88+
const globalBanner = { text: 'Global', type: 'warning' };
89+
const versionBanner = { text: 'v22', type: 'error' };
90+
t.mock.method(global, 'fetch', () =>
91+
Promise.resolve(
92+
makeResponse({ index: globalBanner, v22: versionBanner })
93+
)
94+
);
95+
96+
const result = await fetchBanners('https://example.com/site.json', 22);
97+
98+
assert.equal(result[0], globalBanner);
99+
assert.equal(result[1], versionBanner);
100+
});
101+
102+
it('does not include the version banner when versionMajor is null', async t => {
103+
const globalBanner = { text: 'Global banner', type: 'warning' };
104+
const versionBanner = { text: 'v20 banner', type: 'error' };
105+
t.mock.method(global, 'fetch', () =>
106+
Promise.resolve(
107+
makeResponse({ index: globalBanner, v20: versionBanner })
108+
)
109+
);
110+
111+
const result = await fetchBanners('https://example.com/site.json', null);
112+
113+
assert.deepEqual(result, [globalBanner]);
114+
});
115+
116+
it('returns an empty array when websiteBanners is absent', async t => {
117+
t.mock.method(global, 'fetch', () =>
118+
Promise.resolve({ ok: true, json: async () => ({}) })
119+
);
120+
121+
const result = await fetchBanners('https://example.com/site.json', null);
122+
123+
assert.deepEqual(result, []);
124+
});
125+
});
126+
127+
describe('date filtering', () => {
128+
it('excludes a banner whose endDate has passed', async t => {
129+
const banner = { text: 'Expired', type: 'warning', endDate: PAST };
130+
t.mock.method(global, 'fetch', () =>
131+
Promise.resolve(makeResponse({ index: banner }))
132+
);
133+
134+
const result = await fetchBanners('https://example.com/site.json', null);
135+
136+
assert.deepEqual(result, []);
137+
});
138+
139+
it('excludes a banner whose startDate is in the future', async t => {
140+
const banner = { text: 'Upcoming', type: 'warning', startDate: FUTURE };
141+
t.mock.method(global, 'fetch', () =>
142+
Promise.resolve(makeResponse({ index: banner }))
143+
);
144+
145+
const result = await fetchBanners('https://example.com/site.json', null);
146+
147+
assert.deepEqual(result, []);
148+
});
149+
150+
it('includes a banner within its active date range', async t => {
151+
const banner = {
152+
text: 'Active',
153+
type: 'warning',
154+
startDate: PAST,
155+
endDate: FUTURE,
156+
};
157+
t.mock.method(global, 'fetch', () =>
158+
Promise.resolve(makeResponse({ index: banner }))
159+
);
160+
161+
const result = await fetchBanners('https://example.com/site.json', null);
162+
163+
assert.deepEqual(result, [banner]);
164+
});
165+
});
166+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */
2+
3+
import { isBannerActive } from '../../utils/banner.mjs';
4+
5+
/**
6+
* Fetches and returns active banners for the given version from the remote config.
7+
* Returns an empty array on any fetch or parse failure.
8+
*
9+
* @param {string} remoteConfig
10+
* @param {number | null} versionMajor
11+
* @returns {Promise<BannerEntry[]>}
12+
*/
13+
export const fetchBanners = async (remoteConfig, versionMajor) => {
14+
const res = await fetch(remoteConfig, { signal: AbortSignal.timeout(2500) });
15+
16+
if (!res.ok) {
17+
return [];
18+
}
19+
20+
/** @type {RemoteConfig} */
21+
const config = await res.json();
22+
23+
const active = [];
24+
25+
const globalBanner = config.websiteBanners?.index;
26+
if (globalBanner && isBannerActive(globalBanner)) {
27+
active.push(globalBanner);
28+
}
29+
30+
if (versionMajor != null) {
31+
const versionBanner = config.websiteBanners?.[`v${versionMajor}`];
32+
if (versionBanner && isBannerActive(versionBanner)) {
33+
active.push(versionBanner);
34+
}
35+
}
36+
37+
return active;
38+
};

src/generators/web/ui/components/AnnouncementBanner/index.jsx

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { ArrowUpRightIcon } from '@heroicons/react/24/outline';
22
import Banner from '@node-core/ui-components/Common/Banner';
33
import { useEffect, useState } from 'preact/hooks';
44

5-
import { isBannerActive } from '../../utils/banner.mjs';
5+
import { fetchBanners } from './fetchBanners.mjs';
66

7-
/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */
7+
/** @import { BannerEntry } from './types.d.ts' */
88

99
/**
1010
* Asynchronously fetches and displays announcement banners from the remote config.
@@ -21,36 +21,9 @@ export default ({ remoteConfig, versionMajor }) => {
2121
return;
2222
}
2323

24-
fetch(remoteConfig, {
25-
signal: AbortSignal.timeout(2500),
26-
})
27-
.then(async res => {
28-
if (!res.ok) {
29-
return;
30-
}
31-
32-
/** @type {RemoteConfig} */
33-
const config = await res.json();
34-
35-
const active = [];
36-
37-
const globalBanner = config.websiteBanners?.index;
38-
if (globalBanner && isBannerActive(globalBanner)) {
39-
active.push(globalBanner);
40-
}
41-
42-
if (versionMajor != null) {
43-
const versionBanner = config.websiteBanners[`v${versionMajor}`];
44-
if (versionBanner && isBannerActive(versionBanner)) {
45-
active.push(versionBanner);
46-
}
47-
}
48-
49-
setBanners(active);
50-
})
51-
.catch(error => {
52-
console.error(error);
53-
});
24+
fetchBanners(remoteConfig, versionMajor)
25+
.then(setBanners)
26+
.catch(console.error);
5427
}, []);
5528

5629
if (!banners.length) {

src/generators/web/ui/components/AnnouncementBanner/types.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ export type BannerEntry = {
88
type?: BannerProps['type'];
99
};
1010

11-
export type RemoteConfig = Record<string, { banner?: BannerEntry } | undefined>;
11+
export type RemoteConfig = {
12+
websiteBanners?: Record<string, BannerEntry | undefined>;
13+
};

0 commit comments

Comments
 (0)