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:
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { CreateItem } from "../../shared/types";
|
import type { CreateItem } from "../../shared/types";
|
||||||
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
import { apiDelete, apiGet, apiPost, apiPut, apiUpload } from "../lib/api";
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -96,3 +96,27 @@ export function useDuplicateItem() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useExportItems() {
|
||||||
|
return function exportItems() {
|
||||||
|
window.location.href = "/api/items/export";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
imported: number;
|
||||||
|
createdCategories: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportItems() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) =>
|
||||||
|
apiUpload<ImportResult>("/api/items/import", file),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["totals"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
useApiKeys,
|
useApiKeys,
|
||||||
useAuth,
|
useAuth,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useDeleteApiKey,
|
useDeleteApiKey,
|
||||||
} from "../hooks/useAuth";
|
} from "../hooks/useAuth";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useCurrency } from "../hooks/useCurrency";
|
||||||
|
import { useExportItems, useImportItems } from "../hooks/useItems";
|
||||||
import { useUpdateSetting } from "../hooks/useSettings";
|
import { useUpdateSetting } from "../hooks/useSettings";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||||
import type { Currency, WeightUnit } from "../lib/formatters";
|
import type { Currency, WeightUnit } from "../lib/formatters";
|
||||||
@@ -172,6 +173,95 @@ function ApiKeySection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImportExportSection() {
|
||||||
|
const exportItems = useExportItems();
|
||||||
|
const importItems = useImportItems();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importResult, setImportResult] = useState<{
|
||||||
|
imported: number;
|
||||||
|
createdCategories: string[];
|
||||||
|
errors: string[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setImportResult(null);
|
||||||
|
try {
|
||||||
|
const result = await importItems.mutateAsync(file);
|
||||||
|
setImportResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
setImportResult({
|
||||||
|
imported: 0,
|
||||||
|
createdCategories: [],
|
||||||
|
errors: [(err as Error).message],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Reset so the same file can be imported again if needed
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Import / Export</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Export your gear collection as a CSV file, or import items from a CSV.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={exportItems}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
|
||||||
|
{importItems.isPending ? "Importing..." : "Import CSV"}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
className="hidden"
|
||||||
|
disabled={importItems.isPending}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importResult && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-3 text-xs space-y-1 ${
|
||||||
|
importResult.errors.length > 0 && importResult.imported === 0
|
||||||
|
? "bg-red-50 border border-red-200 text-red-700"
|
||||||
|
: "bg-green-50 border border-green-200 text-green-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{importResult.imported > 0 && (
|
||||||
|
<p className="font-medium">
|
||||||
|
{importResult.imported} item
|
||||||
|
{importResult.imported !== 1 ? "s" : ""} imported.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{importResult.createdCategories.length > 0 && (
|
||||||
|
<p>New categories: {importResult.createdCategories.join(", ")}</p>
|
||||||
|
)}
|
||||||
|
{importResult.errors.map((err, i) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
|
||||||
|
<p key={i} className="text-red-600">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{importResult.imported === 0 && importResult.errors.length === 0 && (
|
||||||
|
<p>No items found in the CSV.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const unit = useWeightUnit();
|
const unit = useWeightUnit();
|
||||||
const currency = useCurrency();
|
const currency = useCurrency();
|
||||||
@@ -255,6 +345,10 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||||
|
<ImportExportSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
{auth?.user && (
|
{auth?.user && (
|
||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||||
<ChangePasswordSection />
|
<ChangePasswordSection />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { zValidator } from "@hono/zod-validator";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
||||||
import { parseId } from "../lib/params.ts";
|
import { parseId } from "../lib/params.ts";
|
||||||
|
import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts";
|
||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
@@ -17,6 +18,27 @@ type Env = { Variables: { db?: any } };
|
|||||||
|
|
||||||
const app = new Hono<Env>();
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
app.get("/export", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const csv = exportItemsCsv(db);
|
||||||
|
c.header("Content-Type", "text/csv");
|
||||||
|
c.header("Content-Disposition", 'attachment; filename="gearbox-export.csv"');
|
||||||
|
return c.body(csv);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/import", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
// Accept either "file" (direct) or "image" (via apiUpload helper)
|
||||||
|
const file = body.file ?? body.image;
|
||||||
|
if (!file || typeof file === "string") {
|
||||||
|
return c.json({ error: "No CSV file provided" }, 400);
|
||||||
|
}
|
||||||
|
const content = await file.text();
|
||||||
|
const result = importItemsCsv(db, content);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/", (c) => {
|
app.get("/", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const items = getAllItems(db);
|
const items = getAllItems(db);
|
||||||
|
|||||||
246
src/server/services/csv.service.ts
Normal file
246
src/server/services/csv.service.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { categories, items } from "../../db/schema.ts";
|
||||||
|
|
||||||
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
|
// ─── CSV serialisation helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeField(value: string | number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
const str = String(value);
|
||||||
|
// Wrap in quotes if the field contains a comma, double-quote, or newline
|
||||||
|
if (
|
||||||
|
str.includes(",") ||
|
||||||
|
str.includes('"') ||
|
||||||
|
str.includes("\n") ||
|
||||||
|
str.includes("\r")
|
||||||
|
) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsvRow(fields: (string | number | null | undefined)[]): string {
|
||||||
|
return fields.map(escapeField).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CSV parsing helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseCsvLine(line: string): string[] {
|
||||||
|
const fields: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i <= line.length) {
|
||||||
|
if (i === line.length) {
|
||||||
|
// End of line — push empty trailing field only if we were expecting one
|
||||||
|
// (handled by the loop condition above + break below)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line[i] === '"') {
|
||||||
|
// Quoted field
|
||||||
|
let field = "";
|
||||||
|
i++; // skip opening quote
|
||||||
|
while (i < line.length) {
|
||||||
|
if (line[i] === '"') {
|
||||||
|
if (i + 1 < line.length && line[i + 1] === '"') {
|
||||||
|
// Escaped quote
|
||||||
|
field += '"';
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
// Closing quote
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field += line[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields.push(field);
|
||||||
|
// Skip comma separator
|
||||||
|
if (i < line.length && line[i] === ",") i++;
|
||||||
|
} else {
|
||||||
|
// Unquoted field — read until comma or end of line
|
||||||
|
const start = i;
|
||||||
|
while (i < line.length && line[i] !== ",") i++;
|
||||||
|
fields.push(line.slice(start, i));
|
||||||
|
if (i < line.length) i++; // skip comma
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsv(content: string): { headers: string[]; rows: string[][] } {
|
||||||
|
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||||
|
const nonEmpty = lines.filter((l) => l.trim() !== "");
|
||||||
|
if (nonEmpty.length === 0) return { headers: [], rows: [] };
|
||||||
|
const headers = parseCsvLine(nonEmpty[0]);
|
||||||
|
const rows = nonEmpty.slice(1).map(parseCsvLine);
|
||||||
|
return { headers, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function exportItemsCsv(db: Db = prodDb): string {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
name: items.name,
|
||||||
|
quantity: items.quantity,
|
||||||
|
weightGrams: items.weightGrams,
|
||||||
|
priceCents: items.priceCents,
|
||||||
|
categoryName: categories.name,
|
||||||
|
notes: items.notes,
|
||||||
|
productUrl: items.productUrl,
|
||||||
|
})
|
||||||
|
.from(items)
|
||||||
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const header =
|
||||||
|
"name,quantity,weightGrams,priceCents,category,notes,productUrl";
|
||||||
|
const dataLines = rows.map((row) =>
|
||||||
|
buildCsvRow([
|
||||||
|
row.name,
|
||||||
|
row.quantity,
|
||||||
|
row.weightGrams,
|
||||||
|
row.priceCents,
|
||||||
|
row.categoryName,
|
||||||
|
row.notes,
|
||||||
|
row.productUrl,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [header, ...dataLines].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Import ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
imported: number;
|
||||||
|
createdCategories: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importItemsCsv(
|
||||||
|
db: Db = prodDb,
|
||||||
|
csvContent: string,
|
||||||
|
): ImportResult {
|
||||||
|
const { headers, rows } = parseCsv(csvContent);
|
||||||
|
|
||||||
|
const result: ImportResult = {
|
||||||
|
imported: 0,
|
||||||
|
createdCategories: [],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (headers.length === 0) return result;
|
||||||
|
|
||||||
|
// Normalise header names for lookup (case-insensitive)
|
||||||
|
const headerIndex = (name: string): number =>
|
||||||
|
headers.findIndex((h) => h.trim().toLowerCase() === name.toLowerCase());
|
||||||
|
|
||||||
|
const nameIdx = headerIndex("name");
|
||||||
|
const quantityIdx = headerIndex("quantity");
|
||||||
|
const weightIdx = headerIndex("weightGrams");
|
||||||
|
const priceIdx = headerIndex("priceCents");
|
||||||
|
const categoryIdx = headerIndex("category");
|
||||||
|
const notesIdx = headerIndex("notes");
|
||||||
|
const urlIdx = headerIndex("productUrl");
|
||||||
|
|
||||||
|
// Build a local category cache (name → id) seeded from the DB
|
||||||
|
const categoryCache = new Map<string, number>();
|
||||||
|
const existingCategories = db
|
||||||
|
.select({ id: categories.id, name: categories.name })
|
||||||
|
.from(categories)
|
||||||
|
.all();
|
||||||
|
for (const cat of existingCategories) {
|
||||||
|
categoryCache.set(cat.name.toLowerCase(), cat.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let rowNum = 0; rowNum < rows.length; rowNum++) {
|
||||||
|
const row = rows[rowNum];
|
||||||
|
const lineNum = rowNum + 2; // 1-based, +1 for header
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = nameIdx >= 0 ? row[nameIdx]?.trim() : undefined;
|
||||||
|
if (!name) {
|
||||||
|
result.errors.push(`Row ${lineNum}: missing required field "name"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category resolution
|
||||||
|
let categoryId: number;
|
||||||
|
const rawCategory = categoryIdx >= 0 ? row[categoryIdx]?.trim() : "";
|
||||||
|
const categoryName = rawCategory || "Uncategorized";
|
||||||
|
const cacheKey = categoryName.toLowerCase();
|
||||||
|
|
||||||
|
if (categoryCache.has(cacheKey)) {
|
||||||
|
categoryId = categoryCache.get(cacheKey)!;
|
||||||
|
} else {
|
||||||
|
// Create a new category
|
||||||
|
const inserted = db
|
||||||
|
.insert(categories)
|
||||||
|
.values({ name: categoryName, icon: "package" })
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
categoryId = inserted.id;
|
||||||
|
categoryCache.set(cacheKey, categoryId);
|
||||||
|
result.createdCategories.push(categoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional numeric fields
|
||||||
|
const rawQuantity = quantityIdx >= 0 ? row[quantityIdx]?.trim() : "";
|
||||||
|
const quantity = rawQuantity ? Number.parseInt(rawQuantity, 10) : 1;
|
||||||
|
if (rawQuantity && Number.isNaN(quantity)) {
|
||||||
|
result.errors.push(
|
||||||
|
`Row ${lineNum}: invalid quantity "${rawQuantity}", skipping`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawWeight = weightIdx >= 0 ? row[weightIdx]?.trim() : "";
|
||||||
|
const weightGrams = rawWeight ? Number.parseFloat(rawWeight) : null;
|
||||||
|
if (rawWeight && Number.isNaN(weightGrams as number)) {
|
||||||
|
result.errors.push(
|
||||||
|
`Row ${lineNum}: invalid weightGrams "${rawWeight}", skipping`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPrice = priceIdx >= 0 ? row[priceIdx]?.trim() : "";
|
||||||
|
const priceCents = rawPrice ? Number.parseInt(rawPrice, 10) : null;
|
||||||
|
if (rawPrice && Number.isNaN(priceCents as number)) {
|
||||||
|
result.errors.push(
|
||||||
|
`Row ${lineNum}: invalid priceCents "${rawPrice}", skipping`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = notesIdx >= 0 ? row[notesIdx]?.trim() || null : null;
|
||||||
|
const productUrl = urlIdx >= 0 ? row[urlIdx]?.trim() || null : null;
|
||||||
|
|
||||||
|
db.insert(items)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
quantity,
|
||||||
|
weightGrams,
|
||||||
|
priceCents,
|
||||||
|
categoryId,
|
||||||
|
notes,
|
||||||
|
productUrl,
|
||||||
|
imageFilename: null,
|
||||||
|
imageSourceUrl: null,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
result.imported++;
|
||||||
|
} catch (err) {
|
||||||
|
result.errors.push(`Row ${lineNum}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -144,4 +144,64 @@ describe("Item Routes", () => {
|
|||||||
});
|
});
|
||||||
expect(res.status).toBe(404);
|
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