- Destructure { db, userId } from createTestDb() in all 8 service test files
- Pass userId to every service function call
- Add cross-user isolation tests for items, categories, threads, setups
- Add composite unique constraint test for categories
- Update verifyApiKey assertions to check { userId } return
- Update verifyAccessToken assertions to check { userId } return
- Pass userId to exchangeCode and refreshAccessToken calls
202 lines
6.4 KiB
TypeScript
202 lines
6.4 KiB
TypeScript
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: any;
|
|
let userId: number;
|
|
|
|
beforeEach(async () => {
|
|
({ db, userId } = await createTestDb());
|
|
});
|
|
|
|
// ── Export ────────────────────────────────────────────────────────────────
|
|
|
|
describe("exportItemsCsv", () => {
|
|
it("returns correct headers on empty collection", async () => {
|
|
const csv = await exportItemsCsv(db, userId);
|
|
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", async () => {
|
|
await createItem(db, userId, {
|
|
name: "Tent",
|
|
weightGrams: 1200,
|
|
priceCents: 35000,
|
|
categoryId: 1,
|
|
notes: "Ultralight",
|
|
productUrl: "https://example.com/tent",
|
|
});
|
|
|
|
const csv = await exportItemsCsv(db, userId);
|
|
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", async () => {
|
|
await createItem(db, userId, {
|
|
name: "Tent, Ultralight",
|
|
categoryId: 1,
|
|
});
|
|
|
|
const csv = await exportItemsCsv(db, userId);
|
|
const lines = csv.split("\n");
|
|
expect(lines[1]).toContain('"Tent, Ultralight"');
|
|
});
|
|
|
|
it("properly escapes fields with double quotes", async () => {
|
|
await createItem(db, userId, {
|
|
name: 'He said "great tent"',
|
|
categoryId: 1,
|
|
});
|
|
|
|
const csv = await exportItemsCsv(db, userId);
|
|
const lines = csv.split("\n");
|
|
expect(lines[1]).toContain('"He said ""great tent"""');
|
|
});
|
|
|
|
it("exports multiple items", async () => {
|
|
await createItem(db, userId, { name: "Tent", categoryId: 1 });
|
|
await createItem(db, userId, {
|
|
name: "Sleeping Bag",
|
|
categoryId: 1,
|
|
});
|
|
|
|
const csv = await exportItemsCsv(db, userId);
|
|
const lines = csv.split("\n");
|
|
expect(lines).toHaveLength(3); // header + 2 items
|
|
});
|
|
|
|
it("exports quantity correctly", async () => {
|
|
// Insert directly to set quantity > 1 (createItem service defaults to 1)
|
|
await db
|
|
.insert(items)
|
|
.values({ name: "Bolt", categoryId: 1, quantity: 4, userId });
|
|
|
|
const csv = await exportItemsCsv(db, userId);
|
|
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", async () => {
|
|
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 = await importItemsCsv(db, userId, csv);
|
|
expect(result.imported).toBe(2);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it("creates missing category and reports it", async () => {
|
|
const csv = [
|
|
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
|
|
"Helmet,1,350,12000,Cycling,,",
|
|
].join("\n");
|
|
|
|
const result = await importItemsCsv(db, userId, 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", async () => {
|
|
const csv = [
|
|
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
|
|
// "uncategorized" should match the seeded "Uncategorized"
|
|
"Spork,1,,,uncategorized,,",
|
|
].join("\n");
|
|
|
|
const result = await importItemsCsv(db, userId, csv);
|
|
expect(result.imported).toBe(1);
|
|
expect(result.createdCategories).toHaveLength(0);
|
|
});
|
|
|
|
it("skips rows with no name and records an error", async () => {
|
|
const csv = [
|
|
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
|
|
",1,200,,,",
|
|
"Tent,1,1200,,,",
|
|
].join("\n");
|
|
|
|
const result = await importItemsCsv(db, userId, 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", async () => {
|
|
const csv = [
|
|
"name,weightGrams,priceCents,category,notes,productUrl",
|
|
"Tent,1200,35000,Camping,,",
|
|
].join("\n");
|
|
|
|
const result = await importItemsCsv(db, userId, csv);
|
|
expect(result.imported).toBe(1);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it("handles optional fields being empty", async () => {
|
|
const csv = [
|
|
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
|
|
"Tent,,,,,",
|
|
].join("\n");
|
|
|
|
const result = await importItemsCsv(db, userId, csv);
|
|
expect(result.imported).toBe(1);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it("handles quoted fields containing commas", async () => {
|
|
const csv = [
|
|
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
|
|
'"Tent, Ultralight",1,1200,,,',
|
|
].join("\n");
|
|
|
|
const result = await importItemsCsv(db, userId, csv);
|
|
expect(result.imported).toBe(1);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it("returns zero imported on empty CSV", async () => {
|
|
const result = await importItemsCsv(db, userId, "");
|
|
expect(result.imported).toBe(0);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it("uses Uncategorized when category column is empty", async () => {
|
|
const csv = [
|
|
"name,quantity,weightGrams,priceCents,category,notes,productUrl",
|
|
"Tent,1,,,,",
|
|
].join("\n");
|
|
|
|
const result = await importItemsCsv(db, userId, csv);
|
|
expect(result.imported).toBe(1);
|
|
expect(result.createdCategories).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|