diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-05-28 21:06:37 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-05-29 22:39:49 +0200 |
commit | a310fc4dde89c6b09a1da4c67dcefb87f2864935 (patch) | |
tree | 30d6b66472f08d6e6de80ba38888d6818ad2fa2a /packages/main/src/infrastructure/config/impl | |
parent | refactor: use undefined constant (diff) | |
download | sophie-a310fc4dde89c6b09a1da4c67dcefb87f2864935.tar.gz sophie-a310fc4dde89c6b09a1da4c67dcefb87f2864935.tar.zst sophie-a310fc4dde89c6b09a1da4c67dcefb87f2864935.zip |
test(main): ConfigFile integration test
Signed-off-by: Kristóf Marussy <kristof@marussy.com>
Diffstat (limited to 'packages/main/src/infrastructure/config/impl')
-rw-r--r-- | packages/main/src/infrastructure/config/impl/ConfigFile.ts | 44 | ||||
-rw-r--r-- | packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts | 293 |
2 files changed, 321 insertions, 16 deletions
diff --git a/packages/main/src/infrastructure/config/impl/ConfigFile.ts b/packages/main/src/infrastructure/config/impl/ConfigFile.ts index 684a827..4f0d4f0 100644 --- a/packages/main/src/infrastructure/config/impl/ConfigFile.ts +++ b/packages/main/src/infrastructure/config/impl/ConfigFile.ts | |||
@@ -32,6 +32,8 @@ import type { ReadConfigResult } from '../ConfigRepository.js'; | |||
32 | 32 | ||
33 | const log = getLogger('ConfigFile'); | 33 | const log = getLogger('ConfigFile'); |
34 | 34 | ||
35 | export const CONFIG_FILE_NAME = 'settings.json'; | ||
36 | |||
35 | export default class ConfigFile implements ConfigRepository { | 37 | export default class ConfigFile implements ConfigRepository { |
36 | private readonly configFilePath: string; | 38 | private readonly configFilePath: string; |
37 | 39 | ||
@@ -41,7 +43,7 @@ export default class ConfigFile implements ConfigRepository { | |||
41 | 43 | ||
42 | constructor( | 44 | constructor( |
43 | private readonly userDataDir: string, | 45 | private readonly userDataDir: string, |
44 | private readonly configFileName = 'settings.json', | 46 | private readonly configFileName = CONFIG_FILE_NAME, |
45 | ) { | 47 | ) { |
46 | this.configFilePath = path.join(userDataDir, configFileName); | 48 | this.configFilePath = path.join(userDataDir, configFileName); |
47 | } | 49 | } |
@@ -65,6 +67,9 @@ export default class ConfigFile implements ConfigRepository { | |||
65 | } | 67 | } |
66 | 68 | ||
67 | async writeConfig(contents: string): Promise<void> { | 69 | async writeConfig(contents: string): Promise<void> { |
70 | if (this.writingConfig) { | ||
71 | throw new Error('writeConfig cannot be called reentrantly'); | ||
72 | } | ||
68 | this.writingConfig = true; | 73 | this.writingConfig = true; |
69 | try { | 74 | try { |
70 | await writeFile(this.configFilePath, contents, 'utf8'); | 75 | await writeFile(this.configFilePath, contents, 'utf8'); |
@@ -95,7 +100,11 @@ export default class ConfigFile implements ConfigRepository { | |||
95 | ); | 100 | ); |
96 | return; | 101 | return; |
97 | } | 102 | } |
98 | throw error; | 103 | log.error( |
104 | 'Unexpected error while listening for config file changes', | ||
105 | error, | ||
106 | ); | ||
107 | return; | ||
99 | } | 108 | } |
100 | if ( | 109 | if ( |
101 | !this.writingConfig && | 110 | !this.writingConfig && |
@@ -111,20 +120,23 @@ export default class ConfigFile implements ConfigRepository { | |||
111 | } | 120 | } |
112 | }, throttleMs); | 121 | }, throttleMs); |
113 | 122 | ||
114 | const watcher = watch(this.userDataDir, { | 123 | const watcher = watch( |
115 | persistent: false, | 124 | this.userDataDir, |
116 | }); | 125 | { |
117 | 126 | persistent: false, | |
118 | watcher.on('change', (eventType, filename) => { | 127 | recursive: false, |
119 | if ( | 128 | }, |
120 | eventType === 'change' && | 129 | (_eventType, filename) => { |
121 | (filename === this.configFileName || filename === null) | 130 | if (filename === this.configFileName || filename === null) { |
122 | ) { | 131 | configChanged()?.catch((err) => { |
123 | configChanged()?.catch((err) => { | 132 | log.error( |
124 | log.error('Unhandled error while listening for config changes', err); | 133 | 'Unhandled error while listening for config changes', |
125 | }); | 134 | err, |
126 | } | 135 | ); |
127 | }); | 136 | }); |
137 | } | ||
138 | }, | ||
139 | ); | ||
128 | 140 | ||
129 | return () => { | 141 | return () => { |
130 | log.trace('Removing watcher for', this.configFilePath); | 142 | log.trace('Removing watcher for', this.configFilePath); |
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..b61e85a --- /dev/null +++ b/packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts | |||
@@ -0,0 +1,293 @@ | |||
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 | writeFile, | ||
30 | } from 'node:fs/promises'; | ||
31 | import { tmpdir } from 'node:os'; | ||
32 | import path from 'node:path'; | ||
33 | |||
34 | import { jest } from '@jest/globals'; | ||
35 | import { mocked } from 'jest-mock'; | ||
36 | |||
37 | import Disposer from '../../../../utils/Disposer.js'; | ||
38 | import ConfigFile, { CONFIG_FILE_NAME } from '../ConfigFile.js'; | ||
39 | |||
40 | const THROTTLE_MS = 1000; | ||
41 | |||
42 | let filesystemDelay = 100; | ||
43 | let realSetTimeout: typeof setTimeout; | ||
44 | let userDataDir: string | undefined; | ||
45 | let configFilePath: string; | ||
46 | let repository: ConfigFile; | ||
47 | |||
48 | /** | ||
49 | * Wait for a short (real-time) delay to let node be notified of file changes. | ||
50 | * | ||
51 | * Will also run all pending microtasks to completion. | ||
52 | * | ||
53 | * Use the `SOPHIE_INTEG_TEST_DELAY` environmental variable to customize the delay | ||
54 | * (e.g., quicker test execution on faster computers or longer wait time in CI). | ||
55 | * The default delay is 100 ms, but as little as 10 ms might be enough, depending on your system. | ||
56 | * | ||
57 | * @returns A promise that resolves in a short while. | ||
58 | */ | ||
59 | function catchUpWithFilesystem(): Promise<void> { | ||
60 | return new Promise((resolve, reject) => { | ||
61 | realSetTimeout(() => { | ||
62 | try { | ||
63 | jest.runAllTicks(); | ||
64 | } catch (error) { | ||
65 | reject(error); | ||
66 | return; | ||
67 | } | ||
68 | resolve(); | ||
69 | }, filesystemDelay); | ||
70 | }); | ||
71 | } | ||
72 | |||
73 | async function catchUpWithFilesystemAndTimers(): Promise<void> { | ||
74 | await catchUpWithFilesystem(); | ||
75 | jest.runAllTimers(); | ||
76 | await catchUpWithFilesystem(); | ||
77 | } | ||
78 | |||
79 | beforeAll(() => { | ||
80 | const delayEnv = process.env.SOPHIE_INTEG_TEST_DELAY; | ||
81 | if (delayEnv !== undefined) { | ||
82 | filesystemDelay = Number.parseInt(delayEnv, 10); | ||
83 | } | ||
84 | jest.useRealTimers(); | ||
85 | // Save the real implementation of `setTimeout` before we mock it. | ||
86 | realSetTimeout = setTimeout; | ||
87 | jest.useFakeTimers(); | ||
88 | }); | ||
89 | |||
90 | beforeEach(async () => { | ||
91 | try { | ||
92 | userDataDir = await mkdtemp(path.join(tmpdir(), 'sophie-configFile-')); | ||
93 | configFilePath = path.join(userDataDir, CONFIG_FILE_NAME); | ||
94 | repository = new ConfigFile(userDataDir); | ||
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, THROTTLE_MS); | ||
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 catchUpWithFilesystemAndTimers(); | ||
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, THROTTLE_MS); | ||
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 catchUpWithFilesystemAndTimers(); | ||
214 | expect(callback).not.toHaveBeenCalled(); | ||
215 | }); | ||
216 | |||
217 | test('throttles notifications of external changes to the config file', async () => { | ||
218 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
219 | await catchUpWithFilesystem(); | ||
220 | expect(callback).toHaveBeenCalledTimes(1); | ||
221 | mocked(callback).mockClear(); | ||
222 | |||
223 | jest.advanceTimersByTime(100); | ||
224 | await writeFile(configFilePath, 'Howdy Venus!', 'utf8'); | ||
225 | await catchUpWithFilesystem(); | ||
226 | |||
227 | jest.advanceTimersByTime(100); | ||
228 | await catchUpWithFilesystem(); | ||
229 | expect(callback).not.toHaveBeenCalled(); | ||
230 | |||
231 | jest.advanceTimersByTime(THROTTLE_MS); | ||
232 | await catchUpWithFilesystem(); | ||
233 | expect(callback).toHaveBeenCalledTimes(1); | ||
234 | }); | ||
235 | |||
236 | test('handles the config file being deleted', async () => { | ||
237 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
238 | // Clear the mock if we inadverently set off `callback`. | ||
239 | mocked(callback).mockClear(); | ||
240 | await rm(configFilePath); | ||
241 | await catchUpWithFilesystemAndTimers(); | ||
242 | expect(callback).not.toHaveBeenCalled(); | ||
243 | |||
244 | await writeFile(configFilePath, 'Hello World!', 'utf8'); | ||
245 | await catchUpWithFilesystem(); | ||
246 | expect(callback).toHaveBeenCalled(); | ||
247 | }); | ||
248 | |||
249 | test('handles the config file being renamed', async () => { | ||
250 | const renamedPath = `${configFilePath}.renamed`; | ||
251 | |||
252 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
253 | // Clear the mock if we inadverently set off `callback`. | ||
254 | mocked(callback).mockClear(); | ||
255 | await rename(configFilePath, renamedPath); | ||
256 | await catchUpWithFilesystemAndTimers(); | ||
257 | expect(callback).not.toHaveBeenCalled(); | ||
258 | |||
259 | await writeFile(renamedPath, 'Hello World!', 'utf8'); | ||
260 | await catchUpWithFilesystemAndTimers(); | ||
261 | expect(callback).not.toHaveBeenCalled(); | ||
262 | |||
263 | await writeFile(configFilePath, 'Hello World!', 'utf8'); | ||
264 | await catchUpWithFilesystem(); | ||
265 | expect(callback).toHaveBeenCalled(); | ||
266 | }); | ||
267 | |||
268 | test('handles other filesystem errors', async () => { | ||
269 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
270 | // Clear the mock if we inadverently set off `callback`. | ||
271 | mocked(callback).mockClear(); | ||
272 | const { mode } = await stat(userDataDir!); | ||
273 | // Remove permission to force a filesystem error. | ||
274 | // eslint-disable-next-line no-bitwise -- Compute reduced permissions. | ||
275 | await chmod(userDataDir!, mode & 0o666); | ||
276 | try { | ||
277 | await catchUpWithFilesystemAndTimers(); | ||
278 | expect(callback).not.toHaveBeenCalled(); | ||
279 | } finally { | ||
280 | await chmod(userDataDir!, mode); | ||
281 | } | ||
282 | }); | ||
283 | |||
284 | test('handles callback errors', async () => { | ||
285 | mocked(callback).mockRejectedValue(new Error('Test error')); | ||
286 | await writeFile(configFilePath, 'Hi Mars!', 'utf8'); | ||
287 | await catchUpWithFilesystem(); | ||
288 | // This test would fail an unhandled promise rejection error if `ConfigFile` | ||
289 | // did not handle the rejection properly. | ||
290 | expect(callback).toHaveBeenCalled(); | ||
291 | }); | ||
292 | }); | ||
293 | }); | ||