Skip to content

Commit f850677

Browse files
committed
feat(upload): CSV and XLSX file parser with size validation
1 parent 26a5d3f commit f850677

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect } from "vitest"
2+
import { parseFileBuffer, validateFileSize, getFileType } from "../upload-parser"
3+
4+
describe("getFileType", () => {
5+
it("returns csv for .csv files", () => {
6+
expect(getFileType("data.csv")).toBe("csv")
7+
})
8+
9+
it("returns xlsx for .xlsx files", () => {
10+
expect(getFileType("data.xlsx")).toBe("xlsx")
11+
})
12+
13+
it("returns null for unsupported extensions", () => {
14+
expect(getFileType("data.pdf")).toBeNull()
15+
expect(getFileType("data.json")).toBeNull()
16+
})
17+
18+
it("handles uppercase extensions", () => {
19+
expect(getFileType("DATA.CSV")).toBe("csv")
20+
expect(getFileType("DATA.XLSX")).toBe("xlsx")
21+
})
22+
})
23+
24+
describe("validateFileSize", () => {
25+
it("returns true for files under 50MB", () => {
26+
expect(validateFileSize(1024 * 1024)).toBe(true)
27+
expect(validateFileSize(50 * 1024 * 1024 - 1)).toBe(true)
28+
})
29+
30+
it("returns false for files at or over 50MB", () => {
31+
expect(validateFileSize(50 * 1024 * 1024)).toBe(false)
32+
expect(validateFileSize(100 * 1024 * 1024)).toBe(false)
33+
})
34+
})
35+
36+
describe("parseFileBuffer", () => {
37+
it("parses CSV content into headers and rows", async () => {
38+
const csv = "Name,Age,City\nAlice,30,Mobile\nBob,25,Birmingham\n"
39+
const buffer = Buffer.from(csv)
40+
const result = await parseFileBuffer(buffer, "csv")
41+
42+
expect(result.headers).toEqual(["Name", "Age", "City"])
43+
expect(result.rows).toHaveLength(2)
44+
expect(result.rows[0]).toEqual({ Name: "Alice", Age: "30", City: "Mobile" })
45+
expect(result.rows[1]).toEqual({ Name: "Bob", Age: "25", City: "Birmingham" })
46+
})
47+
48+
it("handles CSV with quoted fields containing commas", async () => {
49+
const csv = 'Name,Description\nAlice,"Has, commas"\n'
50+
const buffer = Buffer.from(csv)
51+
const result = await parseFileBuffer(buffer, "csv")
52+
53+
expect(result.rows[0].Description).toBe("Has, commas")
54+
})
55+
56+
it("respects maxRows parameter", async () => {
57+
const csv = "X\na\nb\nc\nd\ne\n"
58+
const buffer = Buffer.from(csv)
59+
const result = await parseFileBuffer(buffer, "csv", 3)
60+
61+
expect(result.rows).toHaveLength(3)
62+
expect(result.totalRows).toBe(5)
63+
})
64+
65+
it("trims whitespace from headers", async () => {
66+
const csv = " Name , Age \nAlice,30\n"
67+
const buffer = Buffer.from(csv)
68+
const result = await parseFileBuffer(buffer, "csv")
69+
70+
expect(result.headers).toEqual(["Name", "Age"])
71+
})
72+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Papa from "papaparse"
2+
import * as XLSX from "xlsx"
3+
4+
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
5+
6+
export interface ParseResult {
7+
headers: string[]
8+
rows: Record<string, string>[]
9+
totalRows: number
10+
}
11+
12+
export function getFileType(filename: string): "csv" | "xlsx" | null {
13+
const ext = filename.toLowerCase().split(".").pop()
14+
if (ext === "csv") return "csv"
15+
if (ext === "xlsx" || ext === "xls") return "xlsx"
16+
return null
17+
}
18+
19+
export function validateFileSize(bytes: number): boolean {
20+
return bytes < MAX_FILE_SIZE
21+
}
22+
23+
export async function parseFileBuffer(
24+
buffer: Buffer,
25+
fileType: "csv" | "xlsx",
26+
maxRows?: number
27+
): Promise<ParseResult> {
28+
if (fileType === "xlsx") {
29+
return parseXlsx(buffer, maxRows)
30+
}
31+
return parseCsv(buffer, maxRows)
32+
}
33+
34+
function parseCsv(buffer: Buffer, maxRows?: number): Promise<ParseResult> {
35+
return new Promise((resolve, reject) => {
36+
const text = buffer.toString("utf-8")
37+
38+
const result = Papa.parse<Record<string, string>>(text, {
39+
header: true,
40+
skipEmptyLines: true,
41+
transformHeader: (h: string) => h.trim(),
42+
})
43+
44+
if (result.errors.length > 0 && result.data.length === 0) {
45+
reject(new Error(`CSV parse error: ${result.errors[0].message}`))
46+
return
47+
}
48+
49+
const headers = result.meta.fields ?? []
50+
const allRows = result.data
51+
const rows = maxRows ? allRows.slice(0, maxRows) : allRows
52+
53+
resolve({ headers, rows, totalRows: allRows.length })
54+
})
55+
}
56+
57+
function parseXlsx(buffer: Buffer, maxRows?: number): Promise<ParseResult> {
58+
return new Promise((resolve, reject) => {
59+
try {
60+
const workbook = XLSX.read(buffer, { type: "buffer" })
61+
const sheetName = workbook.SheetNames[0]
62+
if (!sheetName) {
63+
reject(new Error("Excel file has no sheets"))
64+
return
65+
}
66+
67+
const sheet = workbook.Sheets[sheetName]
68+
const jsonData = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, {
69+
defval: "",
70+
raw: false,
71+
})
72+
73+
if (jsonData.length === 0) {
74+
resolve({ headers: [], rows: [], totalRows: 0 })
75+
return
76+
}
77+
78+
const headers = Object.keys(jsonData[0]).map((h) => h.trim())
79+
const rows = maxRows ? jsonData.slice(0, maxRows) : jsonData
80+
81+
resolve({ headers, rows, totalRows: jsonData.length })
82+
} catch (err) {
83+
reject(new Error(`Excel parse error: ${(err as Error).message}`))
84+
}
85+
})
86+
}

0 commit comments

Comments
 (0)