mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-28 00:47:07 +00:00
test: pin the v1 overwrite/collision wire contract for move and copy
The collision tests asserted only statusCode 409, which is how the item_with_same_name_exists code regressed to 'conflict' unnoticed and broke every replace/skip prompt in the GUI. Assert the legacy code and entry_name explicitly, and add controller tests for the overwrite path: the replaced entry must ride along as 'overwritten' in the /copy and /move responses and be announced via outer.gui.item.removed so clients drop its row.
This commit is contained in:
@@ -928,6 +928,87 @@ describe('LegacyFSController.copy', () => {
|
||||
// The entry must still exist — the ghost handler must NOT have run.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces a name collision, then reports and removes the replaced entry on overwrite', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/dup.txt`;
|
||||
const existing = `/${username}/Pictures/dup.txt`;
|
||||
for ( const p of [src, existing] ) {
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: p }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Without overwrite: the v1 conflict contract the GUI's
|
||||
// replace/skip prompts key on.
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.copy(
|
||||
makeReq({
|
||||
body: {
|
||||
source: src,
|
||||
destination: `/${username}/Pictures`,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
legacyCode: 'item_with_same_name_exists',
|
||||
fields: { entry_name: 'dup.txt' },
|
||||
});
|
||||
|
||||
const replaced = (await server.stores.fsEntry.getEntryByPath(
|
||||
existing,
|
||||
))!;
|
||||
|
||||
// With overwrite: the replaced entry rides along in the response
|
||||
// (so the caller can drop its row) and item.removed tells every
|
||||
// other client to do the same — without it they keep a ghost row
|
||||
// until the directory is re-listed.
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
let body: Array<{
|
||||
copied: { path: string };
|
||||
overwritten?: { id: string };
|
||||
}>;
|
||||
let removedCall: (typeof emitSpy.mock.calls)[number] | undefined;
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.copy(
|
||||
makeReq({
|
||||
body: {
|
||||
source: src,
|
||||
destination: `/${username}/Pictures`,
|
||||
overwrite: true,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
body = captured.body as typeof body;
|
||||
removedCall = emitSpy.mock.calls.find(
|
||||
([eventName]) => eventName === 'outer.gui.item.removed',
|
||||
);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(body[0].copied.path).toBe(existing);
|
||||
expect(body[0].overwritten?.id).toBe(replaced.uuid);
|
||||
expect(removedCall).toBeTruthy();
|
||||
const removedPayload = removedCall?.[1] as {
|
||||
response?: { uid?: string };
|
||||
};
|
||||
expect(removedPayload.response?.uid).toBe(replaced.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
// ── move ────────────────────────────────────────────────────────────
|
||||
@@ -1004,6 +1085,89 @@ describe('LegacyFSController.move', () => {
|
||||
const body = captured.body as { moved: { path: string } };
|
||||
expect(body.moved.path).toBe(`/${username}/Pictures/bar`);
|
||||
});
|
||||
|
||||
it('surfaces a name collision, then reports and removes the replaced entry on overwrite', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/clash.txt`;
|
||||
const existing = `/${username}/Pictures/clash.txt`;
|
||||
for ( const p of [src, existing] ) {
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: p }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Without overwrite: the v1 conflict contract the GUI's
|
||||
// replace/skip prompts key on.
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.move(
|
||||
makeReq({
|
||||
body: {
|
||||
source: src,
|
||||
destination: `/${username}/Pictures`,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
legacyCode: 'item_with_same_name_exists',
|
||||
fields: { entry_name: 'clash.txt' },
|
||||
});
|
||||
|
||||
const replaced = (await server.stores.fsEntry.getEntryByPath(
|
||||
existing,
|
||||
))!;
|
||||
|
||||
// With overwrite: the replaced entry rides along in the response
|
||||
// (so the caller can drop its row) and item.removed tells every
|
||||
// other client to do the same — without it they keep a ghost row
|
||||
// until the directory is re-listed.
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
let body: {
|
||||
moved: { path: string };
|
||||
old_path: string;
|
||||
overwritten?: { id: string };
|
||||
};
|
||||
let removedCall: (typeof emitSpy.mock.calls)[number] | undefined;
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.move(
|
||||
makeReq({
|
||||
body: {
|
||||
source: src,
|
||||
destination: `/${username}/Pictures`,
|
||||
overwrite: true,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
body = captured.body as typeof body;
|
||||
removedCall = emitSpy.mock.calls.find(
|
||||
([eventName]) => eventName === 'outer.gui.item.removed',
|
||||
);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(body.old_path).toBe(src);
|
||||
expect(body.moved.path).toBe(existing);
|
||||
expect(body.overwritten?.id).toBe(replaced.uuid);
|
||||
expect(removedCall).toBeTruthy();
|
||||
const removedPayload = removedCall?.[1] as {
|
||||
response?: { uid?: string };
|
||||
};
|
||||
expect(removedPayload.response?.uid).toBe(replaced.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
// ── search ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2248,6 +2248,10 @@ describe('FSService move', () => {
|
||||
fs.move(user.userId, { source, destinationParent: destination }),
|
||||
);
|
||||
expect(conflict.statusCode).toBe(409);
|
||||
// v1 wire contract: the GUI's replace/skip prompts key on this
|
||||
// code + entry_name; a generic 'conflict' makes them fail silently.
|
||||
expect(conflict.legacyCode).toBe('item_with_same_name_exists');
|
||||
expect(conflict.fields).toMatchObject({ entry_name: 'coll.txt' });
|
||||
|
||||
const deduped = await fs.move(user.userId, {
|
||||
source,
|
||||
@@ -2461,16 +2465,17 @@ describe('FSService copy', () => {
|
||||
);
|
||||
await writeFile(user, `${user.home}/Desktop/cp-coll.txt`, 'existing');
|
||||
|
||||
expect(
|
||||
(
|
||||
await caught(() =>
|
||||
fs.copy(user.userId, {
|
||||
source,
|
||||
destinationParent: destination,
|
||||
}),
|
||||
)
|
||||
).statusCode,
|
||||
).toBe(409);
|
||||
const conflict = await caught(() =>
|
||||
fs.copy(user.userId, {
|
||||
source,
|
||||
destinationParent: destination,
|
||||
}),
|
||||
);
|
||||
expect(conflict.statusCode).toBe(409);
|
||||
// v1 wire contract: the GUI's replace/skip prompts key on this
|
||||
// code + entry_name; a generic 'conflict' makes them fail silently.
|
||||
expect(conflict.legacyCode).toBe('item_with_same_name_exists');
|
||||
expect(conflict.fields).toMatchObject({ entry_name: 'cp-coll.txt' });
|
||||
|
||||
const deduped = await fs.copy(user.userId, {
|
||||
source,
|
||||
|
||||
Reference in New Issue
Block a user