diff options
Diffstat (limited to 'packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts')
-rw-r--r-- | packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts b/packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts new file mode 100644 index 0000000..c443d99 --- /dev/null +++ b/packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts | |||
@@ -0,0 +1,289 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import { | ||
22 | chmod, | ||
23 | mkdir, | ||
24 | mkdtemp, | ||
25 | readFile, | ||
26 | rename, | ||
27 | rm, | ||
28 | stat, | ||
29 | utimes, | ||
30 | writeFile, | ||
31 | } from 'node:fs/promises'; | ||
32 | import { platform, tmpdir, userInfo } from 'node:os'; | ||
33 | import path from 'node:path'; | ||
34 | |||
35 | import { jest } from '@jest/globals'; | ||
36 | import { testIf } from '@sophie/test-utils'; | ||
37 | import { mocked } from 'jest-mock'; | ||
38 | |||
39 | import Disposer from '../../../../utils/Disposer.js'; | ||
40 | import ConfigFile, { CONFIG_FILE_NAME } from '../ConfigFile.js'; | ||
41 | |||
42 | const CONFIG_CHANGE_DEBOUNCE_MS = 10; | ||
43 | |||
44 | let filesystemDelay = 100; | ||
45 | let realSetTimeout: typeof setTimeout; | ||
46 | let userDataDir: string | undefined; | ||
47 | let configFilePath: string; | ||
48 | let repository: ConfigFile; | ||
49 | |||
50 | async function realDelay(ms: number): Promise<void> { | ||
51 | return new Promise<void>((resolve) => { | ||
52 | realSetTimeout(() => resolve(), ms); | ||
53 | }); | ||
54 | } | ||
55 | |||
56 | /** | ||
57 | * Wait for a short (real-time) delay to let node be notified of file changes. | ||
58 | * | ||
59 | * Use the `SOPHIE_INTEG_TEST_DELAY` environmental variable to customize the delay | ||
60 | * (e.g., quicker test execution on faster computers or longer wait time in CI). | ||
61 | * The default delay is 100 ms, but as little as 10 ms might be enough, depending on your system. | ||
62 | * | ||
63 | * @param ms The time that should elapse after noticing the filesystem change. | ||
64 | * @returns A promise that resolves in a `filesystemDelay` ms. | ||
65 | */ | ||
66 | async function catchUpWithFilesystem( | ||
67 | ms = CONFIG_CHANGE_DEBOUNCE_MS, | ||
68 | ): Promise<void> { | ||
69 | await realDelay(filesystemDelay); | ||
70 | jest.advanceTimersByTime(ms); | ||
71 | // Some extra real time is needed after advancing the timer to make sure the callback runs. | ||
72 | await realDelay(Math.max(filesystemDelay / 10, 1)); | ||
73 | } | ||
74 | |||
75 | beforeAll(() => { | ||
76 | const delayEnv = process.env.SOPHIE_INTEG_TEST_DELAY; | ||
77 | if (delayEnv !== undefined) { | ||
78 | filesystemDelay = Number.parseInt(delayEnv, 10); | ||
79 | } | ||
80 | jest.useRealTimers(); | ||
81 | // Save the real implementation of `setTimeout` before we mock it. | ||
82 | realSetTimeout = setTimeout; | ||
83 | jest.useFakeTimers(); | ||
84 | }); | ||
85 | |||
86 | beforeEach(async () => { | ||
87 | try { | ||
88 | userDataDir = await mkdtemp(path.join(tmpdir(), 'sophie-configFile-')); | ||
89 | configFilePath = path.join(userDataDir, CONFIG_FILE_NAME); | ||
90 | repository = new ConfigFile( | ||
91 | userDataDir, | ||
92 | CONFIG_FILE_NAME, | ||
93 | CONFIG_CHANGE_DEBOUNCE_MS, | ||
94 | ); | ||
95 | } catch (error) { | ||
96 | userDataDir = undefined; | ||
97 | throw error; | ||
98 | } | ||
99 | }); | ||
100 | |||
101 | afterEach(async () => { | ||
102 | jest.clearAllTimers(); | ||
103 | if (userDataDir === undefined) { | ||
104 | return; | ||
105 | } | ||
106 | if (!userDataDir.startsWith(tmpdir())) { | ||
107 | throw new Error( | ||
108 | `Refusing to delete directory ${userDataDir} outside of tmp directory`, | ||
109 | ); | ||
110 | } | ||
111 | await rm(userDataDir, { | ||
112 | force: true, | ||
113 | recursive: true, | ||
114 | }); | ||
115 | userDataDir = undefined; | ||
116 | }); | ||
117 | |||
118 | describe('readConfig', () => { | ||
119 | test('returns false when the config file is not found', async () => { | ||
120 | const result = await repository.readConfig(); | ||
121 | expect(result.found).toBe(false); | ||
122 | }); | ||
123 | |||
124 | test('reads the contents of the config file if found', async () => { | ||
125 | await writeFile(configFilePath, 'Hello World!', 'utf8'); | ||
126 | const result = await repository.readConfig(); | ||
127 | expect(result.found).toBe(true); | ||
128 | expect(result).toHaveProperty('contents', 'Hello World!'); | ||
129 | }); | ||
130 | |||
131 | test('throws an error if the config file cannot be read', async () => { | ||
132 | await mkdir(configFilePath); | ||
133 | await expect(repository.readConfig()).rejects.toThrow(); | ||
134 | }); | ||
135 | }); | ||
136 | |||
137 | describe('writeConfig', () => { | ||
138 | test('writes the config file', async () => { | ||
139 | await repository.writeConfig('Hi Mars!'); | ||
140 | const contents = await readFile(configFilePath, 'utf8'); | ||
141 | expect(contents).toBe('Hi Mars!'); | ||
142 | }); | ||
143 | |||
144 | test('overwrites the config file', async () => { | ||
145 | await writeFile(configFilePath, 'Hello World!', 'utf8'); | ||
146 | await repository.writeConfig('Hi Mars!'); | ||
147 | const contents = await readFile(configFilePath, 'utf8'); | ||
148 | expect(contents).toBe('Hi Mars!'); | ||
149 | }); | ||
150 | |||
151 | test('throws an error if the config file cannot be written', async () => { | ||
152 | await mkdir(configFilePath); | ||
153 | await expect(repository.writeConfig('Hi Mars!')).rejects.toThrow(); | ||
154 | }); | ||
155 | |||
156 | test('throws an error when called reentrantly', async () => { | ||
157 | const promise = repository.writeConfig('Hello World!'); | ||
158 | try { | ||
159 | await expect(repository.writeConfig('Hi Mars!')).rejects.toThrow(); | ||
160 | } finally { | ||
161 | await promise; | ||
162 | } | ||
163 | }); | ||
164 | }); | ||
165 | |||
166 | describe('watchConfig', () => { | ||
167 | let callback: () => Promise<void>; | ||
168 | let watcher: Disposer | undefined; | ||
169 | |||
170 | beforeEach(() => { | ||
171 | callback = jest.fn(() => Promise.resolve()); | ||
172 | }); | ||
173 | |||
174 | afterEach(() => { | ||
175 | if (watcher !== undefined) { | ||
176 | // Make sure we dispose the watcher. | ||
177 | watcher(); | ||
178 | } | ||
179 | }); | ||
180 | |||
181 | describe('when the config file does not exist', () => { | ||
182 | beforeEach(() => { | ||
183 | watcher = repository.watchConfig(callback); | ||
184 | }); | ||
185 | |||
186 | test('notifies when the config file is created externally', async () => { | ||
187 | await writeFile(configFilePath, 'Hello World!', 'utf8'); | ||
188 | await catchUpWithFilesystem(); | ||
189 | expect(callback).toHaveBeenCalled(); | ||
190 | }); | ||
191 | |||
192 | test('does not notify when the config file is created by the repository', async () => { | ||
193 | await repository.writeConfig('Hello World!'); | ||
194 | await catchUpWithFilesystem(); | ||
195 | expect(callback).not.toHaveBeenCalled(); | ||
196 | }); | ||
197 | }); | ||
198 | |||
199 | describe('when the config file already exists', () => { | ||
200 | beforeEach(async () => { | ||
201 | await writeFile(configFilePath, 'Hello World!', 'utf8'); | ||
202 | watcher = repository.watchConfig(callback); | ||
203 | }); | ||
204 | |||
205 | test('notifies when the config file is updated externally', async () => { | ||
206 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
207 | await catchUpWithFilesystem(); | ||
208 | expect(callback).toHaveBeenCalled(); | ||
209 | }); | ||
210 | |||
211 | test('does not notify when the config file is created by the repository', async () => { | ||
212 | await repository.writeConfig('Hi Mars!'); | ||
213 | await catchUpWithFilesystem(); | ||
214 | expect(callback).not.toHaveBeenCalled(); | ||
215 | }); | ||
216 | |||
217 | test('debounces changes to the config file', async () => { | ||
218 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
219 | await catchUpWithFilesystem(5); | ||
220 | expect(callback).not.toHaveBeenCalled(); | ||
221 | |||
222 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
223 | await catchUpWithFilesystem(); | ||
224 | expect(callback).toHaveBeenCalledTimes(1); | ||
225 | }); | ||
226 | |||
227 | test('handles the config file being deleted', async () => { | ||
228 | await rm(configFilePath); | ||
229 | await catchUpWithFilesystem(); | ||
230 | expect(callback).not.toHaveBeenCalled(); | ||
231 | |||
232 | await writeFile(configFilePath, 'Hello World!', 'utf8'); | ||
233 | await catchUpWithFilesystem(); | ||
234 | expect(callback).toHaveBeenCalled(); | ||
235 | }); | ||
236 | |||
237 | test('handles the config file being renamed', async () => { | ||
238 | const renamedPath = `${configFilePath}.renamed`; | ||
239 | |||
240 | await rename(configFilePath, renamedPath); | ||
241 | await catchUpWithFilesystem(); | ||
242 | expect(callback).not.toHaveBeenCalled(); | ||
243 | |||
244 | await writeFile(renamedPath, 'Hello World!', 'utf8'); | ||
245 | await catchUpWithFilesystem(); | ||
246 | expect(callback).not.toHaveBeenCalled(); | ||
247 | |||
248 | await writeFile(configFilePath, 'Hello World!', 'utf8'); | ||
249 | await catchUpWithFilesystem(); | ||
250 | expect(callback).toHaveBeenCalled(); | ||
251 | }); | ||
252 | |||
253 | // We can only cause a filesystem error by changing permissions if we run on a POSIX-like | ||
254 | // system and we aren't root (i.e., not in CI). | ||
255 | testIf(platform() !== 'win32' && userInfo().uid !== 0)( | ||
256 | 'handles other filesystem errors', | ||
257 | async () => { | ||
258 | const { mode } = await stat(userDataDir!); | ||
259 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
260 | // Remove permission to force a filesystem error. | ||
261 | // eslint-disable-next-line no-bitwise -- Compute reduced permissions. | ||
262 | await chmod(userDataDir!, mode & 0o666); | ||
263 | try { | ||
264 | await catchUpWithFilesystem(); | ||
265 | expect(callback).not.toHaveBeenCalled(); | ||
266 | } finally { | ||
267 | await chmod(userDataDir!, mode); | ||
268 | } | ||
269 | }, | ||
270 | ); | ||
271 | |||
272 | test('does not notify when the modification date is prior to the last write', async () => { | ||
273 | await repository.writeConfig('Hello World!'); | ||
274 | const date = new Date(Date.now() - 3_600_000); | ||
275 | await utimes(configFilePath, date, date); | ||
276 | await catchUpWithFilesystem(); | ||
277 | expect(callback).not.toHaveBeenCalled(); | ||
278 | }); | ||
279 | |||
280 | test('handles callback errors', async () => { | ||
281 | mocked(callback).mockRejectedValue(new Error('Test error')); | ||
282 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
283 | await catchUpWithFilesystem(); | ||
284 | // This test would fail an unhandled promise rejection error if `ConfigFile` | ||
285 | // did not handle the rejection properly. | ||
286 | expect(callback).toHaveBeenCalled(); | ||
287 | }); | ||
288 | }); | ||
289 | }); | ||