forked from Chia-Network/chia-blockchain-gui
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCacheManager.test.ts
More file actions
221 lines (181 loc) · 7.5 KB
/
Copy pathCacheManager.test.ts
File metadata and controls
221 lines (181 loc) · 7.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
type DownloadFile = typeof import('./utils/downloadFile').default;
const mockDownloadFile = jest.fn<ReturnType<DownloadFile>, Parameters<DownloadFile>>();
jest.mock('electron', () => ({
BrowserWindow: jest.fn(),
dialog: {
showOpenDialog: jest.fn(),
},
}));
jest.mock('./utils/downloadFile', () => ({
__esModule: true,
default: mockDownloadFile,
MAX_FILE_SIZE_EXCEEDED_ERROR: 'Maximum file size exceeded',
}));
jest.mock('./utils/ipcMainHandle', () => ({
__esModule: true,
default: jest.fn(),
}));
const CacheManager = jest.requireActual<typeof import('./CacheManager')>('./CacheManager').default;
describe('CacheManager eviction', () => {
let cacheDirectory: string;
beforeEach(async () => {
mockDownloadFile.mockReset();
cacheDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'chia-cache-manager-'));
});
afterEach(async () => {
await fs.rm(cacheDirectory, { recursive: true, force: true });
});
it('does not evict a just-downloaded file that fits within the configured total size', async () => {
const payload = Buffer.alloc(600, 7);
mockDownloadFile.mockImplementation(async (_url, localPath) => {
await fs.writeFile(localPath, payload);
return {
'content-type': 'image/png',
};
});
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();
await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
await expect(cacheManager.getCacheSize()).resolves.toBeLessThanOrEqual(1024);
});
it('keeps a completed download cached when cache housekeeping fails', async () => {
const payload = Buffer.from('cached payload');
mockDownloadFile.mockImplementation(async (_url, localPath) => {
await fs.writeFile(localPath, payload);
return {
'content-type': 'image/png',
};
});
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();
// A concurrent invalidation can delete files mid-scan and make the
// post-download size check fail — that must not poison the download.
jest
.spyOn(cacheManager, 'getCacheSize')
.mockRejectedValueOnce(new Error("ENOENT: no such file or directory, stat '/cache/other-chiacache'"));
await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
});
it('ignores files that vanish while the cache size is being measured', async () => {
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();
await fs.writeFile(path.join(cacheDirectory, 'aaaa-chiacache'), Buffer.alloc(100));
// a broken symlink stats like a file deleted between readdir and stat
await fs.symlink(path.join(cacheDirectory, 'missing-target'), path.join(cacheDirectory, 'bbbb-chiacache'));
await expect(cacheManager.getCacheSize()).resolves.toBe(100);
});
it('evicts without failing when a file vanishes during the eviction scan', async () => {
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();
await fs.writeFile(path.join(cacheDirectory, 'aaaa-chiacache'), Buffer.alloc(200));
await fs.symlink(path.join(cacheDirectory, 'missing-target'), path.join(cacheDirectory, 'bbbb-chiacache'));
await expect(cacheManager.setMaxCacheSize(100)).resolves.toBeUndefined();
await expect(fs.stat(path.join(cacheDirectory, 'aaaa-chiacache'))).rejects.toThrow('ENOENT');
});
it('does not retry a timed-out download on the next access', async () => {
mockDownloadFile.mockRejectedValue(new Error('Request timed out after 30000ms of inactivity'));
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();
await expect(cacheManager.getContent('https://example.com/nft.png')).rejects.toThrow('Request timed out');
await expect(cacheManager.getContent('https://example.com/nft.png')).rejects.toThrow('Request timed out');
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
});
it('retries an aborted download on the next access', async () => {
const payload = Buffer.from('cached payload');
mockDownloadFile.mockRejectedValueOnce(new Error('Request aborted')).mockImplementation(async (_url, localPath) => {
await fs.writeFile(localPath, payload);
return {
'content-type': 'image/png',
};
});
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();
await expect(cacheManager.getContent('https://example.com/nft.png')).rejects.toThrow('Request aborted');
await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
expect(mockDownloadFile).toHaveBeenCalledTimes(2);
});
it('does not overlap cache size scans when a scan outlives the coalescing window', async () => {
jest.useFakeTimers();
try {
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();
let runningScans = 0;
let maxConcurrentScans = 0;
const scanResolvers: Array<() => void> = [];
const getCacheSizeSpy = jest.spyOn(cacheManager, 'getCacheSize').mockImplementation(
() =>
new Promise<number>((resolve) => {
runningScans += 1;
maxConcurrentScans = Math.max(maxConcurrentScans, runningScans);
scanResolvers.push(() => {
runningScans -= 1;
resolve(0);
});
}),
);
const send = jest.fn();
const fakeWindow = {
webContents: { send },
isDestroyed: () => false,
on: jest.fn(),
} as any;
cacheManager.bindEvents(fakeWindow);
cacheManager.emit('sizeChanged');
jest.advanceTimersByTime(500); // the first scan starts and stays in flight
cacheManager.emit('sizeChanged'); // burst arriving mid-scan
jest.advanceTimersByTime(500); // previously this started an overlapping scan
expect(maxConcurrentScans).toBe(1);
scanResolvers.shift()?.();
await Promise.resolve(); // let the first scan settle and reschedule
jest.advanceTimersByTime(500); // the follow-up scan delivers the fresh size
expect(getCacheSizeSpy).toHaveBeenCalledTimes(2);
expect(maxConcurrentScans).toBe(1);
} finally {
jest.useRealTimers();
}
});
it('treats a zero cache limit as unlimited when updating the setting', async () => {
const payload = Buffer.from('cached payload');
mockDownloadFile.mockImplementation(async (_url, localPath) => {
await fs.writeFile(localPath, payload);
return {
'content-type': 'image/png',
};
});
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();
await cacheManager.getContent('https://example.com/nft.png');
await cacheManager.setMaxCacheSize(0);
expect(cacheManager.maxCacheSize).toBe(0);
await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
});
});