mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(cases): rename a case and its folder, with a lock and live-session handling
This commit is contained in:
1 parent
002de32a08
commit
ab0bc0aecd
14 files changed
+884
No files matched your search
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @fileoverview The Rename button's flow in session-ui.js (renameCase): the prompts, the second confirmation
|
||||
* before sessions are stopped, and the follow-up refresh. Evaluates the real file against a stub app.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
function makeApp(opts: { prompt: string | null; confirms: boolean[]; responses: unknown[]; cases: unknown[] }) {
|
||||
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
const confirmMessages: string[] = [];
|
||||
const toasts: string[] = [];
|
||||
const store = new Map<string, string>([['caseSettings_alpha', '{"agentTeams":true}']]);
|
||||
const queue = [...opts.responses];
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: { getElementById: () => ({ value: 'alpha' }) },
|
||||
prompt: () => opts.prompt,
|
||||
confirm: (m: string) => (confirmMessages.push(m), opts.confirms.shift() ?? false),
|
||||
localStorage: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
fetch: async (url: string, init: { body: string }) => {
|
||||
calls.push({ url, body: JSON.parse(init.body) });
|
||||
return { json: async () => queue.shift() };
|
||||
},
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
|
||||
const refreshed: unknown[] = [];
|
||||
Object.assign(app, {
|
||||
cases: opts.cases,
|
||||
showToast: (m: string) => void toasts.push(m),
|
||||
loadQuickStartCases: async (n: unknown) => void refreshed.push(n),
|
||||
renderCaseManageList: () => void refreshed.push('render'),
|
||||
});
|
||||
return { app, calls, confirmMessages, toasts, store, refreshed };
|
||||
}
|
||||
|
||||
const ok = { success: true, data: {} };
|
||||
|
||||
describe('renameCase (case manager)', () => {
|
||||
it('renames a local case, moves its run options and reselects it under the new name', async () => {
|
||||
const t = makeApp({
|
||||
prompt: ' beta ',
|
||||
confirms: [],
|
||||
responses: [ok],
|
||||
cases: [{ name: 'alpha', location: 'local' }],
|
||||
});
|
||||
await t.app.renameCase('alpha');
|
||||
expect(t.calls).toEqual([{ url: '/api/cases/alpha/rename', body: { newName: 'beta' } }]);
|
||||
expect(t.store.get('caseSettings_beta')).toBe('{"agentTeams":true}');
|
||||
expect(t.store.has('caseSettings_alpha')).toBe(false);
|
||||
expect(t.refreshed).toEqual(['beta', 'render']);
|
||||
});
|
||||
|
||||
it('does nothing for a cancelled prompt, the same name, or a name that is not allowed', async () => {
|
||||
for (const entered of [null, '', 'alpha', 'a b', '../x']) {
|
||||
const t = makeApp({ prompt: entered, confirms: [], responses: [], cases: [{ name: 'alpha' }] });
|
||||
await t.app.renameCase('alpha');
|
||||
expect(t.calls, String(entered)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('asks a linked case whether to rename the real folder, and sends the answer', async () => {
|
||||
const t = makeApp({
|
||||
prompt: 'beta',
|
||||
confirms: [true],
|
||||
responses: [ok],
|
||||
cases: [{ name: 'alpha', linked: true, path: '/srv/app' }],
|
||||
});
|
||||
await t.app.renameCase('alpha');
|
||||
expect(t.confirmMessages[0]).toContain('/srv/app');
|
||||
expect(t.calls[0].body).toEqual({ newName: 'beta', renameFolder: true });
|
||||
});
|
||||
|
||||
it('lists the live sessions and only stops them after a second confirmation', async () => {
|
||||
const refusal = {
|
||||
success: false,
|
||||
error: '1 live session has this folder open.',
|
||||
requiresStop: true,
|
||||
sessions: [{ id: 's', name: 'w1-alpha' }],
|
||||
};
|
||||
const t = makeApp({ prompt: 'beta', confirms: [true], responses: [refusal, ok], cases: [{ name: 'alpha' }] });
|
||||
await t.app.renameCase('alpha');
|
||||
expect(t.confirmMessages[0]).toContain('w1-alpha');
|
||||
expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]);
|
||||
|
||||
const declined = makeApp({ prompt: 'beta', confirms: [false], responses: [refusal], cases: [{ name: 'alpha' }] });
|
||||
await declined.app.renameCase('alpha');
|
||||
expect(declined.calls).toHaveLength(1);
|
||||
expect(declined.refreshed).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows the server’s message when the folder is locked, and leaves the lists alone', async () => {
|
||||
const locked = { success: false, error: 'The folder is in use by another program (EBUSY).' };
|
||||
const t = makeApp({ prompt: 'beta', confirms: [], responses: [locked], cases: [{ name: 'alpha' }] });
|
||||
await t.app.renameCase('alpha');
|
||||
expect(t.toasts).toEqual(['The folder is in use by another program (EBUSY).']);
|
||||
expect(t.refreshed).toEqual([]);
|
||||
expect(t.store.has('caseSettings_alpha')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @fileoverview src/case-rename.ts: the in-process lock, the retried folder move and Claude history
|
||||
* migration. Real filesystem under test/setup.ts's temp HOME. Port: N/A.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
CaseRenameError,
|
||||
claudeProjectDirName,
|
||||
isCasePathLocked,
|
||||
moveCaseFolder,
|
||||
moveClaudeHistory,
|
||||
renameInOrder,
|
||||
siblingPath,
|
||||
withCaseLock,
|
||||
} from '../src/case-rename.js';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
const tmp = () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'case-rename-'));
|
||||
roots.push(dir);
|
||||
return dir;
|
||||
};
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of roots.splice(0)) await fsp.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('withCaseLock', () => {
|
||||
it('holds both paths and everything inside them for the length of the call, then lets go', async () => {
|
||||
const root = tmp();
|
||||
const from = join(root, 'old');
|
||||
const to = join(root, 'new');
|
||||
let during: boolean[] = [];
|
||||
await withCaseLock(from, to, async () => {
|
||||
during = [
|
||||
isCasePathLocked(from),
|
||||
isCasePathLocked(join(from, 'src')),
|
||||
isCasePathLocked(to),
|
||||
isCasePathLocked(root),
|
||||
];
|
||||
});
|
||||
expect(during).toEqual([true, true, true, false]);
|
||||
expect(isCasePathLocked(from)).toBe(false);
|
||||
expect(isCasePathLocked(to)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an overlapping rename with BUSY and releases on failure', async () => {
|
||||
const root = tmp();
|
||||
await withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
|
||||
await expect(withCaseLock(join(root, 'b'), join(root, 'c'), async () => {})).rejects.toMatchObject({
|
||||
code: 'BUSY',
|
||||
});
|
||||
await expect(withCaseLock(join(root, 'a', 'inner'), join(root, 'x'), async () => {})).rejects.toBeInstanceOf(
|
||||
CaseRenameError
|
||||
);
|
||||
});
|
||||
await expect(
|
||||
withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
|
||||
throw new Error('boom');
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
expect(isCasePathLocked(join(root, 'a'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a session from starting in a folder that is being moved', async () => {
|
||||
const root = tmp();
|
||||
const from = join(root, 'case');
|
||||
mkdirSync(from);
|
||||
await withCaseLock(from, join(root, 'renamed'), async () => {
|
||||
expect(() => new Session({ workingDir: from })).toThrow(/being renamed/);
|
||||
expect(() => new Session({ workingDir: join(from, 'sub') })).toThrow(/being renamed/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveCaseFolder', () => {
|
||||
it('moves a folder and its contents', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
writeFileSync(join(root, 'old', 'CLAUDE.md'), 'x');
|
||||
await moveCaseFolder(join(root, 'old'), join(root, 'new'));
|
||||
expect(existsSync(join(root, 'old'))).toBe(false);
|
||||
expect(existsSync(join(root, 'new', 'CLAUDE.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('never replaces an existing folder, even an empty one that rename would silently overwrite', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
mkdirSync(join(root, 'taken'));
|
||||
await expect(moveCaseFolder(join(root, 'old'), join(root, 'taken'))).rejects.toMatchObject({ code: 'EXISTS' });
|
||||
expect(existsSync(join(root, 'old'))).toBe(true);
|
||||
});
|
||||
|
||||
it('retries a lock error, and succeeds once the other program lets go', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
const real = fsp.rename.bind(fsp);
|
||||
let calls = 0;
|
||||
vi.spyOn(fsp, 'rename').mockImplementation(async (a, b) => {
|
||||
if (++calls < 3) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
|
||||
return real(a, b);
|
||||
});
|
||||
await moveCaseFolder(join(root, 'old'), join(root, 'new'), 4, 1);
|
||||
expect(calls).toBe(3);
|
||||
expect(existsSync(join(root, 'new'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a lock that never clears as LOCKED, with what to do about it', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('perm'), { code: 'EPERM' }));
|
||||
const err = await moveCaseFolder(join(root, 'old'), join(root, 'new'), 2, 1).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(CaseRenameError);
|
||||
expect(err.code).toBe('LOCKED');
|
||||
expect(err.message).toMatch(/in use by another program/);
|
||||
expect(existsSync(join(root, 'old'))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to cross filesystems', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('xdev'), { code: 'EXDEV' }));
|
||||
await expect(moveCaseFolder(join(root, 'old'), join(root, 'new'))).rejects.toMatchObject({ code: 'CROSS_DEVICE' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude history', () => {
|
||||
it('names a project directory the way Claude Code does', () => {
|
||||
expect(claudeProjectDirName('/home/devvyn/codeman-cases/my_case')).toBe('-home-devvyn-codeman-cases-my-case');
|
||||
expect(claudeProjectDirName('/home/devvyn/.dotcase')).toBe('-home-devvyn--dotcase');
|
||||
});
|
||||
|
||||
it('moves the old path’s history to the new one, and leaves existing history alone', async () => {
|
||||
const cfg = tmp();
|
||||
const projects = join(cfg, 'projects');
|
||||
const oldPath = join(tmp(), 'alpha');
|
||||
const newPath = join(tmp(), 'beta');
|
||||
mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true });
|
||||
writeFileSync(join(projects, claudeProjectDirName(oldPath), 'c.jsonl'), '{}');
|
||||
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
|
||||
expect(existsSync(join(projects, claudeProjectDirName(newPath), 'c.jsonl'))).toBe(true);
|
||||
expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(false);
|
||||
|
||||
mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true });
|
||||
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(false); // the target already has history
|
||||
expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(true);
|
||||
expect(await moveClaudeHistory(join(tmp(), 'none'), newPath, cfg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('small helpers', () => {
|
||||
it('renames in an order list keeping the position, and says nothing when absent', () => {
|
||||
expect(renameInOrder(['a', 'b', 'c'], 'b', 'z')).toEqual(['a', 'z', 'c']);
|
||||
expect(renameInOrder(['a'], 'q', 'z')).toBeNull();
|
||||
expect(renameInOrder(undefined, 'a', 'z')).toBeNull();
|
||||
});
|
||||
it('puts the new name next to the old folder', () => {
|
||||
expect(siblingPath('/srv/work/app', 'app2')).toBe('/srv/work/app2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @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 } from 'vitest';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, 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 } from '../../src/web/route-helpers.js';
|
||||
import { claudeProjectDirName } from '../../src/case-rename.js';
|
||||
import { safeRmHomeTree } from '../mocks/index.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')) : {});
|
||||
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('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('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 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 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 }));
|
||||
// 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 });
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(real)).toBe(true);
|
||||
expect(linked()).toEqual({ proj: real });
|
||||
expect(existsSync(join(work, 'renamed'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is admin only 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).not.toBe(200);
|
||||
expect(linked()).toEqual({ proj: real });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user