Skip to content

Commit 820d749

Browse files
committed
6871: Changed to a testable class-based release loader and added tests
1 parent 0f031ae commit 820d749

3 files changed

Lines changed: 182 additions & 35 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,5 @@ phpstan.neon
6666
###> vincentlanglet/twig-cs-fixer ###
6767
/.twig-cs-fixer.cache
6868
###< vincentlanglet/twig-cs-fixer ###
69+
70+
.claude/

assets/shared/release-loader.js

Lines changed: 55 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,83 @@
1-
// Only fetch new release.json if more than 5 minutes have passed.
2-
const configFetchInterval = 5 * 60 * 1000;
1+
const DEFAULT_FETCH_INTERVAL = 5 * 60 * 1000;
32

4-
// Fetched release data.
5-
let releaseData = null;
3+
const DEFAULT_RELEASE = {
4+
releaseTime: null,
5+
releaseTimestamp: null,
6+
releaseVersion: null,
7+
};
68

7-
// Last time the release was fetched.
8-
let latestFetchTimestamp = 0;
9+
class ReleaseLoader {
10+
#releaseData = null;
11+
#latestFetchTimestamp = null;
12+
#activePromise = null;
13+
#fetchFn;
14+
#nowFn;
15+
#fetchInterval;
916

10-
let activePromise = null;
17+
/**
18+
* @param {object} options
19+
* @param {Function} options.fetchFn - Fetch implementation. Defaults to global fetch.
20+
* @param {Function} options.nowFn - Returns current time in ms. Defaults to Date.now.
21+
* @param {number} options.fetchInterval - Cache lifetime in ms. Defaults to 5 minutes.
22+
*/
23+
constructor({
24+
fetchFn = (...args) => fetch(...args),
25+
nowFn = () => Date.now(),
26+
fetchInterval = DEFAULT_FETCH_INTERVAL,
27+
} = {}) {
28+
this.#fetchFn = fetchFn;
29+
this.#nowFn = nowFn;
30+
this.#fetchInterval = fetchInterval;
31+
}
1132

12-
const ReleaseLoader = {
1333
async loadRelease() {
14-
if (activePromise !== null) {
15-
return activePromise;
34+
if (this.#activePromise !== null) {
35+
return this.#activePromise;
1636
}
1737

18-
const nowTimestamp = new Date().getTime();
38+
const nowTimestamp = this.#nowFn();
1939

2040
// Return early without going through activePromise so the caller always
2141
// receives a real promise, not null.
22-
if (latestFetchTimestamp + configFetchInterval >= nowTimestamp) {
23-
return Promise.resolve(releaseData);
42+
if (
43+
this.#latestFetchTimestamp !== null &&
44+
this.#latestFetchTimestamp + this.#fetchInterval >= nowTimestamp
45+
) {
46+
return Promise.resolve(this.#releaseData);
2447
}
2548

26-
activePromise = fetch(`/release.json?t=${nowTimestamp}`)
49+
this.#activePromise = this.#fetchFn(`/release.json?t=${nowTimestamp}`)
2750
.then((response) => response.json())
2851
.then((data) => {
29-
latestFetchTimestamp = nowTimestamp;
30-
releaseData = data;
31-
return releaseData;
52+
this.#latestFetchTimestamp = nowTimestamp;
53+
this.#releaseData = data;
54+
return this.#releaseData;
3255
})
3356
.catch(() => {
34-
if (releaseData !== null) {
35-
// Bug 3 fix: advance the timestamp so the next call uses the
36-
// cache instead of immediately retrying after a failed fetch.
37-
latestFetchTimestamp = nowTimestamp;
38-
return releaseData;
57+
if (this.#releaseData !== null) {
58+
// Advance the timestamp so the next call uses the cache instead of
59+
// immediately retrying after a failed fetch.
60+
this.#latestFetchTimestamp = nowTimestamp;
61+
return this.#releaseData;
3962
}
4063

4164
/* eslint-disable-next-line no-console */
4265
console.warn("Could not find release.json. Returning defaults.");
4366

44-
// Return defaults.
45-
return {
46-
releaseTime: null,
47-
releaseTimestamp: null,
48-
releaseVersion: null,
49-
};
67+
return DEFAULT_RELEASE;
5068
})
5169
.finally(() => {
5270
// Always clear activePromise via finally so concurrent callers share a
53-
// single in-flight fetch and it is cleared on both success and failure.
54-
activePromise = null;
71+
// single in-flight fetch. It is cleared on both success and failure.
72+
this.#activePromise = null;
5573
});
5674

57-
return activePromise;
58-
},
59-
};
75+
return this.#activePromise;
76+
}
77+
}
6078

61-
Object.freeze(ReleaseLoader);
79+
// Default singleton for production use.
80+
const releaseLoader = new ReleaseLoader();
81+
export default releaseLoader;
6282

63-
export default ReleaseLoader;
83+
export { ReleaseLoader };
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { test, expect } from "@playwright/test";
2+
import { ReleaseLoader } from "../../shared/release-loader.js";
3+
4+
const RELEASE_DATA = {
5+
releaseTime: "2024-01-01T00:00:00Z",
6+
releaseTimestamp: 1704067200,
7+
releaseVersion: "1.0.0",
8+
};
9+
10+
function createMockFetch(response = RELEASE_DATA) {
11+
return () =>
12+
Promise.resolve({
13+
json: () => Promise.resolve(response),
14+
});
15+
}
16+
17+
function createFailingFetch() {
18+
return () => Promise.reject(new Error("Network error"));
19+
}
20+
21+
test.describe("ReleaseLoader", () => {
22+
test("It fetches and returns release data", async () => {
23+
const loader = new ReleaseLoader({ fetchFn: createMockFetch() });
24+
const result = await loader.loadRelease();
25+
26+
expect(result).toEqual(RELEASE_DATA);
27+
});
28+
29+
test("It returns cached data within the fetch interval", async () => {
30+
let fetchCount = 0;
31+
const fetchFn = () => {
32+
fetchCount += 1;
33+
return createMockFetch()();
34+
};
35+
36+
const loader = new ReleaseLoader({ fetchFn, nowFn: () => 1000 });
37+
38+
await loader.loadRelease();
39+
await loader.loadRelease();
40+
41+
expect(fetchCount).toBe(1);
42+
});
43+
44+
test("It fetches again after the interval has passed", async () => {
45+
let fetchCount = 0;
46+
const fetchFn = () => {
47+
fetchCount += 1;
48+
return createMockFetch()();
49+
};
50+
51+
let now = 0;
52+
const loader = new ReleaseLoader({
53+
fetchFn,
54+
nowFn: () => now,
55+
fetchInterval: 1000,
56+
});
57+
58+
await loader.loadRelease();
59+
now = 1001;
60+
await loader.loadRelease();
61+
62+
expect(fetchCount).toBe(2);
63+
});
64+
65+
test("It returns defaults when fetch fails and no cached data exists", async () => {
66+
const loader = new ReleaseLoader({ fetchFn: createFailingFetch() });
67+
const result = await loader.loadRelease();
68+
69+
expect(result).toEqual({
70+
releaseTime: null,
71+
releaseTimestamp: null,
72+
releaseVersion: null,
73+
});
74+
});
75+
76+
test("It returns cached data when fetch fails after a successful fetch", async () => {
77+
let shouldFail = false;
78+
const fetchFn = () => {
79+
if (shouldFail) {
80+
return createFailingFetch()();
81+
}
82+
return createMockFetch()();
83+
};
84+
85+
let now = 0;
86+
const loader = new ReleaseLoader({
87+
fetchFn,
88+
nowFn: () => now,
89+
fetchInterval: 1000,
90+
});
91+
92+
await loader.loadRelease();
93+
94+
shouldFail = true;
95+
now = 1001;
96+
const result = await loader.loadRelease();
97+
98+
expect(result).toEqual(RELEASE_DATA);
99+
});
100+
101+
test("It deduplicates concurrent calls", async () => {
102+
let fetchCount = 0;
103+
let resolveResponse;
104+
105+
const fetchFn = () => {
106+
fetchCount += 1;
107+
return new Promise((resolve) => {
108+
resolveResponse = resolve;
109+
});
110+
};
111+
112+
const loader = new ReleaseLoader({ fetchFn });
113+
114+
const promise1 = loader.loadRelease();
115+
const promise2 = loader.loadRelease();
116+
117+
resolveResponse({ json: () => Promise.resolve(RELEASE_DATA) });
118+
119+
const [result1, result2] = await Promise.all([promise1, promise2]);
120+
121+
expect(fetchCount).toBe(1);
122+
expect(result1).toEqual(RELEASE_DATA);
123+
expect(result2).toEqual(RELEASE_DATA);
124+
});
125+
});

0 commit comments

Comments
 (0)