feat: add CSV import/export for gear collection
Some checks failed
CI / ci (pull_request) Failing after 22s
CI / e2e (pull_request) Has been skipped

Adds export (GET /api/items/export) and import (POST /api/items/import) routes
backed by a pure csv.service with no external deps, plus useExportItems/useImportItems
hooks and an Import/Export section in the Settings page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 18:12:07 +02:00
parent 8c1fe47a99
commit 15f146ee89
6 changed files with 645 additions and 2 deletions

View File

@@ -144,4 +144,64 @@ describe("Item Routes", () => {
});
expect(res.status).toBe(404);
});
it("GET /api/items/export returns CSV with correct content-type", async () => {
// Create an item first
await app.request("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: 1,
}),
});
const res = await app.request("/api/items/export");
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/csv");
const text = await res.text();
const lines = text.split("\n");
expect(lines[0]).toBe(
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
);
expect(lines.length).toBeGreaterThanOrEqual(2);
expect(lines[1]).toContain("Tent");
});
it("POST /api/items/import with CSV file creates items", async () => {
const csvContent = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Sleeping Bag,1,800,25000,Camping,,",
].join("\n");
const formData = new FormData();
formData.append(
"file",
new Blob([csvContent], { type: "text/csv" }),
"import.csv",
);
const res = await app.request("/api/items/import", {
method: "POST",
body: formData,
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.imported).toBe(1);
expect(body.errors).toHaveLength(0);
});
it("POST /api/items/import with no file returns 400", async () => {
const res = await app.request("/api/items/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { items } from "../../src/db/schema.ts";
import {
exportItemsCsv,
importItemsCsv,
} from "../../src/server/services/csv.service.ts";
import { createItem } from "../../src/server/services/item.service.ts";
import { createTestDb } from "../helpers/db.ts";
describe("CSV Service", () => {
let db: ReturnType<typeof createTestDb>;
beforeEach(() => {
db = createTestDb();
});
// ── Export ────────────────────────────────────────────────────────────────
describe("exportItemsCsv", () => {
it("returns correct headers on empty collection", () => {
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines[0]).toBe(
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
);
expect(lines).toHaveLength(1);
});
it("exports items with correct values", () => {
createItem(db, {
name: "Tent",
weightGrams: 1200,
priceCents: 35000,
categoryId: 1,
notes: "Ultralight",
productUrl: "https://example.com/tent",
});
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines).toHaveLength(2);
expect(lines[1]).toContain("Tent");
expect(lines[1]).toContain("1200");
expect(lines[1]).toContain("35000");
expect(lines[1]).toContain("Uncategorized");
expect(lines[1]).toContain("Ultralight");
expect(lines[1]).toContain("https://example.com/tent");
});
it("properly escapes fields with commas", () => {
createItem(db, {
name: "Tent, Ultralight",
categoryId: 1,
});
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines[1]).toContain('"Tent, Ultralight"');
});
it("properly escapes fields with double quotes", () => {
createItem(db, {
name: 'He said "great tent"',
categoryId: 1,
});
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines[1]).toContain('"He said ""great tent"""');
});
it("exports multiple items", () => {
createItem(db, { name: "Tent", categoryId: 1 });
createItem(db, { name: "Sleeping Bag", categoryId: 1 });
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
expect(lines).toHaveLength(3); // header + 2 items
});
it("exports quantity correctly", () => {
// Insert directly to set quantity > 1 (createItem service defaults to 1)
db.insert(items)
.values({ name: "Bolt", categoryId: 1, quantity: 4 })
.run();
const csv = exportItemsCsv(db);
const lines = csv.split("\n");
const fields = lines[1].split(",");
// quantity is second field
expect(fields[1]).toBe("4");
});
});
// ── Import ────────────────────────────────────────────────────────────────
describe("importItemsCsv", () => {
it("parses a valid CSV and creates items", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Tent,1,1200,35000,Camping,Ultralight,https://example.com/tent",
"Sleeping Bag,1,800,25000,Camping,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(2);
expect(result.errors).toHaveLength(0);
});
it("creates missing category and reports it", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Helmet,1,350,12000,Cycling,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.createdCategories).toContain("Cycling");
expect(result.errors).toHaveLength(0);
});
it("uses existing category (case-insensitive) without creating a duplicate", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
// "uncategorized" should match the seeded "Uncategorized"
"Spork,1,,,uncategorized,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.createdCategories).toHaveLength(0);
});
it("skips rows with no name and records an error", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
",1,200,,,",
"Tent,1,1200,,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatch(/missing required field "name"/);
});
it("defaults quantity to 1 when not provided", () => {
const csv = [
"name,weightGrams,priceCents,category,notes,productUrl",
"Tent,1200,35000,Camping,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.errors).toHaveLength(0);
});
it("handles optional fields being empty", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Tent,,,,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.errors).toHaveLength(0);
});
it("handles quoted fields containing commas", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
'"Tent, Ultralight",1,1200,,,',
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.errors).toHaveLength(0);
});
it("returns zero imported on empty CSV", () => {
const result = importItemsCsv(db, "");
expect(result.imported).toBe(0);
expect(result.errors).toHaveLength(0);
});
it("uses Uncategorized when category column is empty", () => {
const csv = [
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
"Tent,1,,,,",
].join("\n");
const result = importItemsCsv(db, csv);
expect(result.imported).toBe(1);
expect(result.createdCategories).toHaveLength(0);
});
});
});