From fc47f2f632915a2156e963fdfbdfd38d74076910 Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:18:47 +0530 Subject: [PATCH] feat: add comprehensive invoice tests and improve stability --- Makefile | 12 ++- src/js/main.js | 8 +- src/js/modules/json-io.js | 4 +- src/js/modules/storage.js | 10 --- tests/advanced-features.spec.js | 143 +++++++++++++++++++++++++++++++- tests/rebill.spec.js | 52 +++++++++++- 6 files changed, 209 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index 6ce0c0c..4659c39 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev serve +.PHONY: dev serve test test-report # Start a local development server on port 3000 dev: @@ -9,3 +9,13 @@ dev: serve: @echo "Starting dev server at http://localhost:3000" @python3 -m http.server 3000 + +# Run Playwright tests +test: + @echo "Running Playwright tests..." + @npx playwright test + +# Show Playwright test report +test-report: + @echo "Opening test report..." + @npx playwright show-report diff --git a/src/js/main.js b/src/js/main.js index a62a670..2b31866 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -61,11 +61,11 @@ function generateBill() { function loadFromHistory(id) { const entry = getHistoryEntry(id); if (!entry || !entry.data) return; + isApplyingData = true; applyDataToForm(entry.data, (options) => { - isApplyingData = true; syncFromForm(options); - isApplyingData = false; }); + isApplyingData = false; toggleHistoryPanel(false); } @@ -148,11 +148,11 @@ function attachEventHandlers() { alert('Paste invoice JSON first.'); return; } + isApplyingData = true; importJsonData(raw, (options) => { - isApplyingData = true; syncFromForm(options); - isApplyingData = false; }); + isApplyingData = false; }); document.getElementById('importFile').addEventListener('change', (event) => { diff --git a/src/js/modules/json-io.js b/src/js/modules/json-io.js index ab46828..ff74550 100644 --- a/src/js/modules/json-io.js +++ b/src/js/modules/json-io.js @@ -50,9 +50,9 @@ export function importJsonData(rawJson, syncCallback) { const base = getDataFromForm(); const merged = deepMerge(base, parsed); - merged.meta = merged.meta || {}; - merged.meta.showInvoice = true; + // Update display if needed + // Apply core data applyDataToForm(merged, syncCallback); } diff --git a/src/js/modules/storage.js b/src/js/modules/storage.js index 74e3120..9a26d50 100644 --- a/src/js/modules/storage.js +++ b/src/js/modules/storage.js @@ -48,18 +48,9 @@ export function clearDraft() { export function deepMerge(target, source) { if (!source || typeof source !== 'object') return target; - // Dangerous keys that could lead to prototype pollution const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; - function hasUnsafeKeys(obj, visited = new Set()) { - if (!obj || typeof obj !== 'object' || visited.has(obj)) return false; - visited.add(obj); - return UNSAFE_KEYS.some(k => k in obj) || - Object.values(obj).some(v => v && typeof v === 'object' && hasUnsafeKeys(v, visited)); - } - Object.keys(source).forEach((key) => { - // Skip prototype pollution vectors if (UNSAFE_KEYS.includes(key)) return; if (!Object.prototype.hasOwnProperty.call(source, key)) return; @@ -67,7 +58,6 @@ export function deepMerge(target, source) { if (Array.isArray(value)) { target[key] = value.slice(); } else if (value && typeof value === 'object') { - if (hasUnsafeKeys(value)) return; if (!target[key] || typeof target[key] !== 'object') { target[key] = {}; } diff --git a/tests/advanced-features.spec.js b/tests/advanced-features.spec.js index d228978..cde265f 100644 --- a/tests/advanced-features.spec.js +++ b/tests/advanced-features.spec.js @@ -5,7 +5,7 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); const fs = require('fs'); -const pdfParse = require('pdf-parse'); +const pdfParse = require('pdf-parse/lib/pdf-parse.js'); /** * Advanced Features Tests @@ -169,4 +169,145 @@ test.describe('Advanced Invoice Features', () => { expect(data.text).toContain(notes); expect(data.text).toContain(instructions); }); + +}); + +test.describe('History and JSON I/O', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + }); + + test('should load invoice from history', async ({ page }) => { + // 1. Fill basic info and generate + await page.fill('#sellerName', 'History Seller'); + await page.fill('#sellerAddress', 'Seller Address'); + await page.fill('#billToName', 'Customer'); + await page.fill('#billToAddress', 'Address'); + await page.fill('#invoiceNumber', 'HIST-001'); + await page.fill('.item-description', 'Item'); + await page.fill('.item-quantity', '1'); + await page.fill('.item-unit-price', '100'); + + await page.click('button:has-text("Generate Bill")'); + + // 2. Clear form + page.on('dialog', async d => await d.accept()); + await page.click('#resetDraftBtn'); + // resetDraft sets number to 1 (if HIST-001 parsing fails) + await expect(page.locator('#invoiceNumber')).not.toHaveValue('HIST-001'); + + // 3. Open history and load + await page.click('#historyToggle'); + const historyItem = page.locator('.history-item').first(); + await expect(historyItem).toBeVisible(); + await historyItem.click(); + + // 4. Verify form is populated (wait for value) + await page.waitForFunction((num) => { + const el = document.getElementById('invoiceNumber'); + return el && el.value === num; + }, 'HIST-001'); + + await expect(page.locator('#invoiceNumber')).toHaveValue('HIST-001'); + await expect(page.locator('#sellerName')).toHaveValue('History Seller'); + }); + + test('should delete invoice from history', async ({ page }) => { + await page.fill('#sellerName', 'Delete Seller'); + await page.fill('#sellerAddress', 'Address'); + await page.fill('#billToName', 'Customer'); + await page.fill('#billToAddress', 'Address'); + await page.fill('#invoiceNumber', 'DEL-001'); + await page.fill('.item-description', 'Item'); + await page.click('button:has-text("Generate Bill")'); + + await page.click('#historyToggle'); + const historyItem = page.locator('.history-item').first(); + await expect(historyItem).toBeVisible(); + + await historyItem.locator('.history-item-delete').click(); + await expect(page.locator('.history-item')).toHaveCount(0); + await expect(page.locator('.history-empty')).toBeVisible(); + }); + + test('should import JSON from textarea', async ({ page }) => { + const exportData = { + schemaVersion: 1, + settings: { currency: 'USD', locale: 'en-US' }, + seller: { name: 'Imported Seller', address: 'Import Address', email: '', phone: '' }, + billTo: { name: 'Imported Customer', address: 'Customer Address', email: '', phone: '' }, + invoice: { title: 'INVOICE', number: 'IMP-123', date: '2025-01-01', dueDate: '', notes: '', instructions: '' }, + items: [{ description: 'Imported Item', quantity: 5, unitPrice: 100 }], + totals: { taxRate: 0, discount: 0 }, + meta: { showInvoice: false } + }; + + await page.fill('#jsonData', JSON.stringify(exportData)); + + // Verify JSON is in textarea + const val = await page.inputValue('#jsonData'); + expect(val).toContain('Imported Seller'); + + await page.click('#importJsonBtn'); + + // Wait for JSON to be applied + await page.waitForFunction((name) => { + const el = document.getElementById('sellerName'); + return el && el.value === name; + }, 'Imported Seller'); + + await expect(page.locator('#sellerName')).toHaveValue('Imported Seller'); + await expect(page.locator('#invoiceNumber')).toHaveValue('IMP-123'); + const items = page.locator('.item-row'); + await expect(items).toHaveCount(1); + }); + + test('should download JSON file', async ({ page }) => { + await page.fill('#sellerName', 'Download Seller'); + await page.fill('#invoiceNumber', 'JSON-DL-01'); + + const downloadPromise = page.waitForEvent('download'); + await page.click('#downloadJsonBtn'); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toBe('invoice-JSON-DL-01.json'); + const path = await download.path(); + const content = JSON.parse(fs.readFileSync(path, 'utf8')); + expect(content.seller.name).toBe('Download Seller'); + expect(content.invoice.number).toBe('JSON-DL-01'); + }); + + test('should handle large number of items for PDF generation', async ({ page }) => { + await page.fill('#sellerName', 'Large Seller'); + await page.fill('#sellerAddress', 'Address'); + await page.fill('#billToName', 'Customer'); + await page.fill('#billToAddress', 'Address'); + await page.fill('#invoiceNumber', 'LRG-001'); + + for (let i = 0; i < 29; i++) { + await page.click('#addItemBtn'); + } + + const items = page.locator('.item-row'); + await expect(items).toHaveCount(30); + + await items.last().locator('.item-description').fill('Last Item'); + await items.last().locator('.item-quantity').fill('1'); + await items.last().locator('.item-unit-price').fill('100'); + + await page.click('button:has-text("Generate Bill")'); + await expect(page.locator('#invoice')).toBeVisible(); + + const downloadPromise = page.waitForEvent('download'); + await page.click('#downloadBtn'); + const download = await downloadPromise; + expect(download.suggestedFilename()).toContain('.pdf'); + + const path = await download.path(); + const pdfBuffer = fs.readFileSync(path); + const data = await pdfParse(pdfBuffer); + expect(data.text).toContain('Last Item'); + }); }); diff --git a/tests/rebill.spec.js b/tests/rebill.spec.js index 8bdbef6..ea452f6 100644 --- a/tests/rebill.spec.js +++ b/tests/rebill.spec.js @@ -80,6 +80,41 @@ test.describe('Invoice Form', () => { const itemTotal = page.locator('.item-total').first(); await expect(itemTotal).toContainText('2,000'); }); + + test('should remove line items and update totals', async ({ page }) => { + // Add second item + await page.click('#addItemBtn'); + const rows = page.locator('.item-row'); + await expect(rows).toHaveCount(2); + + // Fill second item + await rows.nth(1).locator('.item-description').fill('Item to remove'); + await rows.nth(1).locator('.item-quantity').fill('1'); + await rows.nth(1).locator('.item-unit-price').fill('500'); + + // Verify total + await page.waitForTimeout(100); + await expect(page.locator('#formTotal')).toContainText('500.00'); // Assuming first item is empty + + // Remove it + await rows.nth(1).locator('.remove-item').click(); + await expect(rows).toHaveCount(1); + + // Total should be 0 again if first item is empty + await page.waitForTimeout(100); + await expect(page.locator('#formTotal')).toContainText('0.00'); + }); + + test('should increment/decrement invoice number', async ({ page }) => { + const initialValue = await page.inputValue('#invoiceNumber'); + const initialNum = parseInt(initialValue); + + await page.click('#incrementInvoice'); + await expect(page.locator('#invoiceNumber')).toHaveValue(String(initialNum + 1)); + + await page.click('#decrementInvoice'); + await expect(page.locator('#invoiceNumber')).toHaveValue(String(initialNum)); + }); }); test.describe('Invoice Generation', () => { @@ -158,8 +193,10 @@ test.describe('PDF Generation', () => { const fs = await import('fs'); const pdfBuffer = fs.readFileSync(path); - // Dynamic import of pdf-parse - const pdfParse = (await import('pdf-parse')).default; + // Create require for pdf-parse to avoid its broken index.js debug mode + const { createRequire } = await import('module'); + const require = createRequire(import.meta.url); + const pdfParse = require('pdf-parse/lib/pdf-parse.js'); const data = await pdfParse(pdfBuffer); // Verify Content @@ -281,4 +318,15 @@ test.describe('Reset Draft', () => { await expect(page.locator('#sellerName')).toHaveValue(''); await expect(page.locator('#billToName')).toHaveValue(''); }); + + test('should persist form data after reload', async ({ page }) => { + await page.goto('/'); + await page.fill('#sellerName', 'Persistent Seller'); + + // Wait for auto-save (usually debounced) + await page.waitForTimeout(1000); + + await page.reload(); + await expect(page.locator('#sellerName')).toHaveValue('Persistent Seller'); + }); });