Files
Codeman/test/routes/case-rename-routes.test.ts
T
Codeman maintainer 2ec739fd3a test: pin the rename re-checks and the refused-registry answers (release review)
The rename's checks inside mutateLinkedCases and the refused-registry
branches of rename and clone had no test: removing them left every
case route test green. A hook right after the folder move lands a link,
an unlink, a re-pointed entry or a torn file between the route's first
check and its registry write. Each new test fails when its check is
removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:27:39 +01:00

529 lines
25 KiB
TypeScript

/**
* @fileoverview POST /api/cases/:name/rename: a local case moves with its name, a linked case renames its
* alias (and, on request, its real folder), live sessions are listed and only stopped on request, and the
* registries (caseOrder, lastUsedCase, linked-cases.json) and Claude's history follow. Real filesystem under
* test/setup.ts's temp HOME; every folder is removed through safeRmHomeTree. Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, 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';
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');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-');
let work = '';
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 });
const localCase = (name: string) => {
const dir = join(CASES_DIR(), name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'CLAUDE.md'), name);
made.push(dir);
return dir;
};
beforeEach(() => {
if (!sandboxed()) throw new Error('case-rename-routes.test.ts renames folders: run it via npm test');
safeRmHomeTree(LINKED());
work = realpathSync(mkdtempSync(join(homedir(), 'case-rename-')));
claudeCfg = join(work, 'claude-config');
process.env.CLAUDE_CONFIG_DIR = claudeCfg;
mkdirSync(join(homedir(), '.codeman'), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['keep', 'alpha', 'tail'], lastUsedCase: 'alpha' }));
});
afterEach(() => {
delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.CODEMAN_MULTIUSER;
for (const dir of made.splice(0)) safeRmHomeTree(dir);
if (work) safeRmHomeTree(work);
work = '';
if (sandboxed()) safeRmHomeTree(LINKED());
});
describe('POST /api/cases/:name/rename (a case under the cases directory)', () => {
it('moves the folder, keeps its place in the order, follows lastUsedCase and carries Claude history', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
mkdirSync(join(claudeCfg, 'projects', claudeProjectDirName(oldDir)), { recursive: true });
writeFileSync(join(claudeCfg, 'projects', claudeProjectDirName(oldDir), 'c.jsonl'), '{}');
const res = await rename(app, 'alpha', { newName: 'beta' });
expect(res.statusCode).toBe(200);
const newDir = join(CASES_DIR(), 'beta');
made.push(newDir);
expect(res.json().data).toMatchObject({ oldName: 'alpha', newName: 'beta', newPath: newDir, folderMoved: true });
expect(existsSync(oldDir)).toBe(false);
expect(readFileSync(join(newDir, 'CLAUDE.md'), 'utf8')).toBe('alpha');
expect(settings()).toMatchObject({ caseOrder: ['keep', 'beta', 'tail'], lastUsedCase: 'beta' });
expect(existsSync(join(claudeCfg, 'projects', claudeProjectDirName(newDir), 'c.jsonl'))).toBe(true);
});
it('carries Claude history into the project directory a deleted case of the new name left behind', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const newDir = join(CASES_DIR(), 'beta');
const oldKey = join(claudeCfg, 'projects', claudeProjectDirName(oldDir));
const newKey = join(claudeCfg, 'projects', claudeProjectDirName(newDir));
mkdirSync(oldKey, { recursive: true });
writeFileSync(join(oldKey, 'conv-alpha.jsonl'), '{}');
// case delete never removes Claude Code's history, so an earlier "beta" left its project directory
mkdirSync(newKey, { recursive: true });
writeFileSync(join(newKey, 'conv-old-beta.jsonl'), '{}');
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(newDir);
expect(res.statusCode).toBe(200);
expect(existsSync(join(newKey, 'conv-alpha.jsonl'))).toBe(true);
expect(existsSync(join(newKey, 'conv-old-beta.jsonl'))).toBe(true);
expect(existsSync(oldKey)).toBe(false);
});
it('refuses a dangling symlink at the new folder before it stops any session', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const target = join(CASES_DIR(), 'beta');
symlinkSync(join(work, 'nowhere'), target);
made.push(target);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: oldDir });
const res = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
expect(ctx.cleanupSession).not.toHaveBeenCalled();
expect(existsSync(oldDir)).toBe(true);
});
it('refuses a name that is taken (local or linked), the same name, or one that is not allowed', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
localCase('taken');
const target = join(work, 'elsewhere');
mkdirSync(target);
writeFileSync(LINKED(), JSON.stringify({ lk: target }));
expect((await rename(app, 'alpha', { newName: 'taken' })).statusCode).toBe(409);
expect((await rename(app, 'alpha', { newName: 'lk' })).statusCode).toBe(409);
expect((await rename(app, 'alpha', { newName: 'alpha' })).statusCode).toBe(400);
expect((await rename(app, 'alpha', { newName: '../evil' })).statusCode).toBe(400);
expect((await rename(app, 'nope', { newName: 'whatever' })).statusCode).toBe(404);
expect(existsSync(join(CASES_DIR(), 'alpha'))).toBe(true);
});
it('lists the live sessions that have the folder open and does not touch it until told to stop them', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
(ctx.sessions as Map<string, unknown>).set('s-live', {
id: 's-live',
name: 'w1-alpha',
workingDir: join(oldDir, 'sub'),
});
const refused = await rename(app, 'alpha', { newName: 'beta' });
expect(refused.statusCode).toBe(409);
expect(refused.json()).toMatchObject({ requiresStop: true, sessions: [{ id: 's-live', name: 'w1-alpha' }] });
expect(existsSync(oldDir)).toBe(true);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
const done = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
made.push(join(CASES_DIR(), 'beta'));
expect(done.statusCode).toBe(200);
expect(done.json().data.stoppedSessions).toBe(1);
expect(ctx.cleanupSession).toHaveBeenCalledWith('s-live', true, 'case_rename');
expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(true);
});
it('refuses to move a folder a session is still being created in, since it can neither list nor stop it', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
// Constructed but not in ctx.sessions yet: the gap a create spends awaiting the layout lock and settings.
const session = new Session({ workingDir: join(oldDir, 'sub') });
try {
const busy = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
expect(busy.statusCode).toBe(409);
expect(busy.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' });
expect(existsSync(oldDir)).toBe(true);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
// once registered it is an open session like any other: listed, and stopped only on request
(ctx.sessions as Map<string, unknown>).set(session.id, session);
caseSessionSettled(session);
const listed = await rename(app, 'alpha', { newName: 'beta' });
expect(listed.statusCode).toBe(409);
expect(listed.json()).toMatchObject({ requiresStop: true, sessions: [{ id: session.id }] });
} finally {
caseSessionSettled(session);
}
});
it('does not stop a remote session whose remote path equals the local folder', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const dir = localCase('alpha');
(ctx.sessions as Map<string, unknown>).set('s-remote', { id: 's-remote', name: 'r', workingDir: dir, remote: {} });
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(join(CASES_DIR(), 'beta'));
expect(res.statusCode).toBe(200);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
});
it("carries the owner's default session group to the new name", async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
vi.mocked(ctx.tabLayouts.caseDefaultGroup).mockResolvedValueOnce({ groupId: 'g1', groups: [] });
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(join(CASES_DIR(), 'beta'));
expect(res.statusCode).toBe(200);
expect(ctx.tabLayouts.setCaseDefaultGroup).toHaveBeenCalledWith(expect.any(String), 'beta', 'g1');
expect(ctx.tabLayouts.forgetCaseDefaultGroup).toHaveBeenCalledWith(expect.any(String), 'alpha');
});
it('answers a folder that appears at the target with ALREADY_EXISTS and keeps renameCode', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
localCase('beta');
const res = await rename(app, 'alpha', { newName: 'beta' });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
});
it('does not touch a case whose name merely starts with the same letters', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
const sibling = localCase('alpha2');
(ctx.sessions as Map<string, unknown>).set('s-other', { id: 's-other', name: 'x', workingDir: sibling });
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(join(CASES_DIR(), 'beta'));
expect(res.statusCode).toBe(200);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
});
});
describe('POST /api/cases/:name/rename (a linked case)', () => {
it('renames only the alias by default: the real folder and its sessions are left alone', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'o') }));
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
const res = await rename(app, 'proj', { newName: 'renamed' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ folderMoved: false, newPath: real, stoppedSessions: 0 });
expect(linked()).toEqual({ other: join(work, 'o'), renamed: real });
expect(existsSync(real)).toBe(true);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
});
it('renames only the alias while a session is being created in the folder, which an alias rename leaves alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
const session = new Session({ workingDir: real });
try {
expect((await rename(app, 'proj', { newName: 'renamed' })).statusCode).toBe(200);
expect((await rename(app, 'renamed', { newName: 'moved', renameFolder: true })).json()).toMatchObject({
renameCode: 'BUSY',
});
expect(existsSync(real)).toBe(true);
} finally {
caseSessionSettled(session);
}
});
it('renames the real folder too, next to the old one, when asked', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(200);
expect(existsSync(real)).toBe(false);
expect(existsSync(join(work, 'renamed'))).toBe(true);
expect(linked()).toEqual({ renamed: join(work, 'renamed') });
});
it('puts the folder and settings.json back when the registry cannot be written', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
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
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 });
expect(existsSync(join(work, 'renamed'))).toBe(false);
// settings.json is written first, so it must be put back too
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
});
it('puts back only the keys it wrote, keeping a settings write made in between', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
// Another writer (a settings save) lands after the rename wrote settings.json and before the
// registry write fails.
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
} finally {
spy.mockRestore();
}
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');
const torn = '{\n "agentSkillEnabled": true\n}tail"\n}';
writeFileSync(SETTINGS_PATH, torn);
const res = await rename(app, 'alpha', { newName: 'beta' });
expect(res.statusCode).toBe(409);
expect(res.json()).toEqual({
success: false,
errorCode: 'CONFLICT',
error: `${SETTINGS_PATH} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`,
});
expect(existsSync(oldDir)).toBe(true);
expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn);
});
it('checks settings.json before it stops any session, so none is ended for a rename that cannot complete', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const torn = '{\n "agentSkillEnabled": true\n}tail"\n}';
writeFileSync(SETTINGS_PATH, torn);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: oldDir });
const res = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('is not a valid JSON object');
expect(ctx.cleanupSession).not.toHaveBeenCalled();
expect(existsSync(oldDir)).toBe(true);
expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn);
});
it('says where the folder is when it cannot be moved back', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
const moved = join(work, 'renamed');
const spy = failRegistryWrite(undefined, (from) => from === moved);
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
expect(res.json().error).toContain(`is now at ${moved}`);
expect(existsSync(moved)).toBe(true);
} finally {
spy.mockRestore();
}
});
it('reports the sessions it stopped when the rename fails after stopping them', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
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');
expect(existsSync(real)).toBe(true);
});
it('puts the folder and the registry back when settings.json cannot be written', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'x') }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['proj'] }));
// settings.json is replaced by renaming a temp file over it, which a read-only file does not stop,
// so the failure is injected at that rename.
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === SETTINGS_PATH) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
return realRename(from, to);
});
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, other: join(work, 'x') });
// The failed write leaves no temp file behind.
expect(readdirSync(dirname(SETTINGS_PATH)).filter((n) => n.startsWith('settings.json.'))).toEqual([]);
});
it('answers 404 for a linked case a non-admin cannot see in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const real = join(work, 'p');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
const res = await rename(app, 'proj', { newName: 'renamed' });
expect(res.statusCode).toBe(404);
expect(linked()).toEqual({ proj: real });
});
it('leaves the server-wide settings.json alone when a non-admin renames their own case', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const bobsDir = join(resolveCasesDir({ username: 'bob', role: 'user' } as never), 'alpha');
mkdirSync(bobsDir, { recursive: true });
made.push(dirname(bobsDir));
const before = readFileSync(SETTINGS_PATH, 'utf8');
const res = await rename(app, 'alpha', { newName: 'bobs' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(dirname(bobsDir), 'bobs'))).toBe(true);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(before);
});
});