Merge #764 fix(cases): serialize every write to linked-cases.json

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 04:59:52 +01:00
commit 78b1cb26dc
10 files changed
+541 -84

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
/**
* @fileoverview Every writer of the linked-cases registry is serialized, atomic, and refuses to
* overwrite a file that does not parse (`mutateLinkedCases()`, src/web/linked-cases-writer.ts). Real
* filesystem under test/setup.ts's temp HOME. Port: N/A (app.inject()).
*
* The CI flake this fixes was "a name linked while the clone ran is a 409" in
* test/routes/case-clone-custom-folder.test.ts: two clones claiming one name both re-read the
* registry before either wrote it, so both answered success and the later write replaced the
* earlier entry. Writes to the registry (and its temp files) are slowed down here, so two writers
* that both read before either writes lose an entry every time instead of once in a while.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
readlinkSync,
realpathSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { createRouteTestHarness } from './routes/_route-test-utils.js';
import { registerCaseRoutes } from '../src/web/routes/case-routes.js';
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../src/web/linked-cases-writer.js';
import { isSandboxedHome, safeRmHomeTree } from './mocks/index.js';
const LINKED = () => linkedCasesPath();
const linked = (): Record<string, string> => JSON.parse(readFileSync(LINKED(), 'utf8'));
/** Temp files the writer left beside the registry (there must never be any). */
const leftovers = () =>
readdirSync(dirname(LINKED())).filter((n) => n.startsWith(`${basename(LINKED())}.`) && n.endsWith('.tmp'));
/** The bytes of a registry torn by two overlapping truncate-then-write calls. */
const TORN = '{\n "kept": "/srv/kept"\n}' + 'ept-2"\n}';
/** How long every write to the registry (or a temp file beside it) is held before it starts. */
const SLOW_WRITE_MS = 60;
const realWriteFile = fs.writeFile.bind(fs);
let workDir = '';
beforeEach(() => {
if (!isSandboxedHome()) {
throw new Error('linked-cases-writer.test.ts rewrites the linked-cases registry: run it via npm test');
}
safeRmHomeTree(LINKED());
mkdirSync(dirname(LINKED()), { recursive: true });
workDir = realpathSync(mkdtempSync(join(homedir(), 'linked-cases-writer-')));
vi.spyOn(fs, 'writeFile').mockImplementation((async (file: unknown, data: unknown, options?: unknown) => {
if (typeof file === 'string' && file.startsWith(LINKED())) {
await new Promise((resolve) => setTimeout(resolve, SLOW_WRITE_MS));
}
return realWriteFile(file as string, data as string, options as undefined);
}) as typeof fs.writeFile);
});
afterEach(() => {
vi.restoreAllMocks();
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (isSandboxedHome()) safeRmHomeTree(LINKED());
});
describe('mutateLinkedCases()', () => {
it('runs concurrent mutations one at a time, so none loses another one’s entry', async () => {
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
await Promise.all(
Array.from({ length: 8 }, (_, i) => mutateLinkedCases((current) => ({ ...current, [`c${i}`]: `/srv/c${i}` })))
);
expect(Object.keys(linked()).sort()).toEqual(['c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'kept']);
expect(leftovers()).toEqual([]);
});
it('a check that throws inside the mutation rejects that caller only and writes nothing', async () => {
const claim = (name: string, path: string) =>
mutateLinkedCases((current) => {
if (current[name]) throw Object.assign(new Error(`${name} is taken`), { conflict: true });
return { ...current, [name]: path };
});
const results = await Promise.allSettled([
claim('twin', '/srv/a'),
claim('twin', '/srv/b'),
claim('solo', '/srv/s'),
]);
expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'fulfilled']);
expect((results[1] as PromiseRejectedResult).reason.message).toBe('twin is taken');
expect(linked()).toEqual({ twin: '/srv/a', solo: '/srv/s' });
});
it('starts from an empty registry when the file is missing or empty, and writes nothing for undefined', async () => {
const { before, after } = await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
expect(before).toEqual({});
expect(after).toEqual({ a: '/srv/a' });
writeFileSync(LINKED(), ' \n');
await mutateLinkedCases((current) => ({ ...current, b: '/srv/b' }));
expect(linked()).toEqual({ b: '/srv/b' });
const untouched = readFileSync(LINKED(), 'utf8');
expect((await mutateLinkedCases(() => undefined)).after).toBeUndefined();
expect(readFileSync(LINKED(), 'utf8')).toBe(untouched);
});
it('refuses a registry that is not a JSON object and leaves it byte for byte', async () => {
for (const content of [TORN, '[]', 'null', '"text"']) {
writeFileSync(LINKED(), content);
const fn = vi.fn((current: Record<string, string>) => ({ ...current, a: '/srv/a' }));
await expect(mutateLinkedCases(fn)).rejects.toBeInstanceOf(LinkedCasesWriteRefusedError);
expect(fn).not.toHaveBeenCalled();
expect(readFileSync(LINKED(), 'utf8')).toBe(content);
}
// The chain keeps going after a refusal.
writeFileSync(LINKED(), '{}');
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
expect(linked()).toEqual({ a: '/srv/a' });
});
it('leaves the previous registry and no temp file when the write fails', async () => {
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
const realRename = fs.rename.bind(fs);
vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === LINKED()) throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' });
return realRename(from, to);
});
await expect(mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }))).rejects.toThrow('no space left');
expect(linked()).toEqual({ kept: '/srv/kept' });
expect(leftovers()).toEqual([]);
});
it('writes through a symlinked registry onto its target and keeps the file’s permissions', async () => {
const real = join(workDir, 'linked-cases.json');
writeFileSync(real, JSON.stringify({ kept: '/srv/kept' }), { mode: 0o600 });
safeRmHomeTree(LINKED());
symlinkSync(real, LINKED());
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
expect(lstatSync(LINKED()).isSymbolicLink()).toBe(true);
expect(readlinkSync(LINKED())).toBe(real);
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual({ kept: '/srv/kept', a: '/srv/a' });
expect(statSync(real).mode & 0o777).toBe(0o600);
expect(readdirSync(workDir).filter((n) => n.endsWith('.tmp'))).toEqual([]);
});
});
describe('the case routes share the writer', () => {
const folder = (name: string) => {
const dir = join(workDir, name);
mkdirSync(dir);
return dir;
};
it('two links of different names at once both survive', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [a, b] = await Promise.all([
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'one', path: folder('one') } }),
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'two', path: folder('two') } }),
]);
expect([a.json().success, b.json().success]).toEqual([true, true]);
expect(linked()).toEqual({ one: join(workDir, 'one'), two: join(workDir, 'two') });
});
it('two links of one name at once: exactly one wins and the other is told the name is taken', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [a, b] = await Promise.all([
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-a') } }),
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-b') } }),
]);
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
const loser = a.json().success ? b : a;
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
expect(Object.keys(linked())).toEqual(['twin']);
});
it('an unlink at the same time as a link keeps the new link', async () => {
writeFileSync(LINKED(), JSON.stringify({ old: folder('old') }));
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [unlink, link] = await Promise.all([
app.inject({ method: 'DELETE', url: '/api/cases/old' }),
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'fresh', path: folder('fresh') } }),
]);
expect([unlink.json().success, link.json().success]).toEqual([true, true]);
expect(linked()).toEqual({ fresh: join(workDir, 'fresh') });
expect(existsSync(join(workDir, 'old'))).toBe(true);
});
it('a link refuses a registry that does not parse instead of replacing it', async () => {
writeFileSync(LINKED(), TORN);
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'n', path: folder('n') } });
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(res.json().error).toContain('is not a valid JSON object');
expect(readFileSync(LINKED(), 'utf8')).toBe(TORN);
});
});
+15 -1
View File
@@ -20,7 +20,7 @@ import {
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
@@ -178,6 +178,20 @@ describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder',
expect(Object.keys(linked())).toEqual(['twin']);
});
it('a registry that is not a JSON object is a 409 that keeps the folder, names it and leaves the file alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'refused');
mkdirSync(dirname(LINKED()), { recursive: true });
writeFileSync(LINKED(), '[]');
const res = await clone(app, { name: 'refused', repository: origin, path: target });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain(target);
expect(res.json().error).toContain('Use Link Existing');
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(readFileSync(LINKED(), 'utf8')).toBe('[]');
});
it('a direct clone whose git-slot wait times out leaves what was written into the folder meanwhile', async () => {
const target = join(workDir, 'queued');
mkdirSync(target);
+24 -7
View File
@@ -8,7 +8,7 @@
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
* safeRmHomeTree, so a run can only ever delete what it created.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
existsSync,
mkdirSync,
@@ -19,6 +19,7 @@ import {
symlinkSync,
writeFileSync,
} from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
@@ -40,7 +41,7 @@ beforeEach(() => {
if (!sandboxed()) {
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
}
// Recursive: the rollback tests turn the registry into a directory.
// Recursive, so a test that left anything at the registry's path cannot leak into the next one.
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
@@ -134,20 +135,36 @@ describe('POST /api/cases with a custom path', () => {
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
// The registry is replaced by renaming a temp file over it: fail that rename.
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === LINKED()) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
return realRename(from, to);
});
let res;
try {
res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
} finally {
spy.mockRestore();
}
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'doomed'))).toBe(false);
expect(linked()).toEqual({});
});
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
it('undoes only the scaffold inside an empty folder the user picked when the registry is refused', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'picked'));
mkdirSync(LINKED(), { recursive: true });
// A registry that does not parse is never written over (that would unlink every other case).
const torn = '{\n "kept": "/somewhere"\n}tail"\n}';
writeFileSync(LINKED(), torn);
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
expect(res.statusCode).toBe(500);
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('is not a valid JSON object');
expect(existsSync(join(work(), 'picked'))).toBe(true);
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
expect(readFileSync(LINKED(), 'utf8')).toBe(torn);
});
it('a request without a path still creates under the cases directory, as before', async () => {
+111 -17
View File
@@ -6,7 +6,6 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -26,6 +25,7 @@ import { SETTINGS_PATH, resolveCasesDir } from '../../src/web/route-helpers.js';
import { caseSessionSettled, claudeProjectDirName } from '../../src/case-rename.js';
import { Session } from '../../src/session.js';
import { safeRmHomeTree } from '../mocks/index.js';
import { mutateLinkedCases } from '../../src/web/linked-cases-writer.js';
import { mutateSettingsFile } from '../../src/web/settings-writer.js';
const LINKED = () => dataPath('linked-cases.json');
@@ -36,6 +36,32 @@ let claudeCfg = '';
const made: string[] = [];
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
/**
* The registry is replaced by renaming a temp file over it, which a read-only file does not stop, so a
* failed registry write is injected at that rename. `before` runs first (another writer landing then).
*/
const failRegistryWrite = (before?: () => Promise<unknown>, alsoFail?: (from: string) => boolean) => {
const realRename = fs.rename;
return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === LINKED()) {
if (before) await before();
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
}
if (alsoFail?.(String(from))) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
return realRename(from, to);
});
};
/**
* Runs `during` right after `folder` moved: past every check the route makes before it writes the
* registry, and before that write starts. Every other rename goes through untouched.
*/
const afterFolderMove = (folder: string, during: () => Promise<unknown>) => {
const realRename = fs.rename;
return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
await realRename(from, to);
if (from === folder) await during();
});
};
const settings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
const rename = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], name: string, payload: object) =>
app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload });
@@ -278,8 +304,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
// the registry reads fine but cannot be written: the rename must undo the folder move
chmodSync(LINKED(), 0o444);
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
const spy = failRegistryWrite();
let res;
try {
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
} finally {
spy.mockRestore();
}
expect(res.statusCode).toBe(500);
expect(existsSync(real)).toBe(true);
expect(linked()).toEqual({ proj: real });
@@ -294,14 +325,9 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
chmodSync(LINKED(), 0o444);
// Another writer (a settings save) lands after the rename wrote settings.json and before the
// registry write fails.
const realWriteFile = fs.writeFile;
const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => {
if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true }));
return realWriteFile(file, data, options);
});
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
@@ -311,6 +337,74 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
});
describe('when the registry changes after the first check', () => {
let real = '';
type App = Awaited<ReturnType<typeof createRouteTestHarness>>['app'];
const renameRacing = async (app: App, during: () => Promise<unknown>) => {
const spy = afterFolderMove(real, during);
try {
return await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
} finally {
spy.mockRestore();
}
};
const expectFolderAndSettingsBack = () => {
expect(existsSync(real)).toBe(true);
expect(existsSync(join(work, 'renamed'))).toBe(false);
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
};
beforeEach(() => {
real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
});
it('a name linked meanwhile is 409 EXISTS: the link stands and the rename is undone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const other = join(work, 'other');
mkdirSync(other);
const res = await renameRacing(app, () =>
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'renamed', path: other } })
);
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ errorCode: 'ALREADY_EXISTS', renameCode: 'EXISTS', stoppedSessions: 0 });
expect(linked()).toEqual({ proj: real, renamed: other });
expectFolderAndSettingsBack();
});
it('an old entry unlinked meanwhile is 409 BUSY: the rename does not bring it back', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await renameRacing(app, () => app.inject({ method: 'DELETE', url: '/api/cases/proj' }));
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY', stoppedSessions: 0 });
expect(linked()).toEqual({});
expectFolderAndSettingsBack();
});
it('an old entry pointed at another folder meanwhile is 409 BUSY: that entry is kept', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const other = join(work, 'other');
const res = await renameRacing(app, () => mutateLinkedCases((current) => ({ ...current, proj: other })));
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' });
expect(linked()).toEqual({ proj: other });
expectFolderAndSettingsBack();
});
it('a registry that stopped parsing is refused 409 CONFLICT and left byte for byte', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const torn = `{ "proj": "${real}" }tail"`;
const res = await renameRacing(app, async () => writeFileSync(LINKED(), torn));
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ errorCode: 'CONFLICT', stoppedSessions: 0 });
expect(res.json().error).toContain('is not a valid JSON object');
expect(res.json()).not.toHaveProperty('renameCode');
expect(readFileSync(LINKED(), 'utf8')).toBe(torn);
expectFolderAndSettingsBack();
});
});
it('refuses to rename while settings.json is not a JSON object, leaving the folder and the file alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
@@ -351,13 +445,8 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
chmodSync(LINKED(), 0o444);
const moved = join(work, 'renamed');
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (from === moved) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
return realRename(from, to);
});
const spy = failRegistryWrite(undefined, (from) => from === moved);
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
@@ -373,9 +462,14 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
chmodSync(LINKED(), 0o444);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
const spy = failRegistryWrite();
let res;
try {
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
} finally {
spy.mockRestore();
}
expect(res.statusCode).toBe(500);
expect(res.json().stoppedSessions).toBe(1);
expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename');
+7
View File
@@ -54,6 +54,12 @@ vi.mock('node:fs/promises', () => ({
throw err;
}),
writeFile: vi.fn(async () => {}),
// What the linked-cases writer needs to replace the registry atomically (temp file + rename).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
chmod: vi.fn(async () => {}),
rename: vi.fn(async () => {}),
rm: vi.fn(async () => {}),
},
}));
@@ -704,6 +710,7 @@ describe('case-routes', () => {
});
// linked-cases.json doesn't exist yet
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const res = await harness.app.inject({
method: 'POST',