feat: add CSV import/export for gear collection
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
197
tests/services/csv.service.test.ts
Normal file
197
tests/services/csv.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user