aboutsummaryrefslogtreecommitdiffstats
path: root/packages/main/src/infrastructure/config/impl/__tests__/ConfigFile.integ.test.ts
diff options
context:
space:
mode:
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.ts289
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
21import {
22 chmod,
23 mkdir,
24 mkdtemp,
25 readFile,
26 rename,
27 rm,
28 stat,
29 utimes,
30 writeFile,
31} from 'node:fs/promises';
32import { platform, tmpdir, userInfo } from 'node:os';
33import path from 'node:path';
34
35import { jest } from '@jest/globals';
36import { testIf } from '@sophie/test-utils';
37import { mocked } from 'jest-mock';
38
39import Disposer from '../../../../utils/Disposer.js';
40import ConfigFile, { CONFIG_FILE_NAME } from '../ConfigFile.js';
41
42const CONFIG_CHANGE_DEBOUNCE_MS = 10;
43
44let filesystemDelay = 100;
45let realSetTimeout: typeof setTimeout;
46let userDataDir: string | undefined;
47let configFilePath: string;
48let repository: ConfigFile;
49
50async 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 */
66async 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
75beforeAll(() => {
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
86beforeEach(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
101afterEach(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
118describe('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
137describe('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
166describe('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});