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.ts293
1 files changed, 293 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..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
21import {
22 chmod,
23 mkdir,
24 mkdtemp,
25 readFile,
26 rename,
27 rm,
28 stat,
29 writeFile,
30} from 'node:fs/promises';
31import { tmpdir } from 'node:os';
32import path from 'node:path';
33
34import { jest } from '@jest/globals';
35import { mocked } from 'jest-mock';
36
37import Disposer from '../../../../utils/Disposer.js';
38import ConfigFile, { CONFIG_FILE_NAME } from '../ConfigFile.js';
39
40const THROTTLE_MS = 1000;
41
42let filesystemDelay = 100;
43let realSetTimeout: typeof setTimeout;
44let userDataDir: string | undefined;
45let configFilePath: string;
46let 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 */
59function 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
73async function catchUpWithFilesystemAndTimers(): Promise<void> {
74 await catchUpWithFilesystem();
75 jest.runAllTimers();
76 await catchUpWithFilesystem();
77}
78
79beforeAll(() => {
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
90beforeEach(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
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, 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});