feat: add item duplication with copy-and-edit workflow

Adds POST /api/items/:id/duplicate endpoint, useDuplicateItem hook, and a
Duplicate button on ItemCard (collection view only) that opens the new item
for editing immediately after creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 18:07:20 +02:00
parent 818db73432
commit b9a06dd244
5 changed files with 136 additions and 0 deletions

View File

@@ -118,4 +118,30 @@ describe("Item Routes", () => {
const res = await app.request("/api/items/9999");
expect(res.status).toBe(404);
});
it("POST /api/items/:id/duplicate returns 201 with the copy", async () => {
const createRes = await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Tent", categoryId: 1, weightGrams: 1200 }),
});
const created = await createRes.json();
const res = await app.request(`/api/items/${created.id}/duplicate`, {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Tent (copy)");
expect(body.weightGrams).toBe(1200);
expect(body.id).not.toBe(created.id);
});
it("POST /api/items/999/duplicate returns 404", async () => {
const res = await app.request("/api/items/999/duplicate", {
method: "POST",
});
expect(res.status).toBe(404);
});
});

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
import {
createItem,
deleteItem,
duplicateItem,
getAllItems,
getItemById,
updateItem,
@@ -98,6 +99,41 @@ describe("Item Service", () => {
});
});
describe("duplicateItem", () => {
it("creates a copy with '(copy)' suffix in name", () => {
const original = createItem(db, {
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: 1,
notes: "Ultralight",
productUrl: "https://example.com/tent",
});
const copy = duplicateItem(db, original?.id);
expect(copy).toBeDefined();
expect(copy?.name).toBe("Tent (copy)");
expect(copy?.weightGrams).toBe(1200);
expect(copy?.priceCents).toBe(35000);
expect(copy?.categoryId).toBe(1);
expect(copy?.notes).toBe("Ultralight");
expect(copy?.productUrl).toBe("https://example.com/tent");
});
it("copy has a different ID from the original", () => {
const original = createItem(db, { name: "Helmet", categoryId: 1 });
const copy = duplicateItem(db, original?.id);
expect(copy?.id).not.toBe(original?.id);
});
it("returns null for non-existent item", () => {
const result = duplicateItem(db, 9999);
expect(result).toBeNull();
});
});
describe("deleteItem", () => {
it("removes item from DB, returns deleted item", () => {
const created = createItem(db, {