CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
* feat: move login credentials to a DB-backed users table with an admin management page Replaces the hardcoded AUTH_USERNAME/AUTH_PASSWORD login check with a new auth_accounts table (bcrypt-hashed passwords, admin/user roles). Adds admin-only /api/users CRUD routes and a "Brugere" admin page in the frontend for managing logins without redeploying. Removes the unused, unmounted duplicate login route in src/routes/auth.js. * docs: add architecture codemaps with diagrams for the whole system Adds codemaps/architecture.md, backend.md, frontend.md, and data.md — Mermaid-diagrammed design documentation verified against the live codebase and database rather than assumed from CLAUDE.md. Covers the unified-server.js request flow (mounted routers + ~183 inline routes), 68 backend services grouped by domain, the frontend's state-driven view-switch (no React Router in practice despite BrowserRouter being present), and the full 122-table DB schema with the auth_accounts vs unrelated users table naming trap flagged explicitly. Links added from the root README. Co-Authored-By: Claude Sonnet 5 <[email protected]> * feat: ship canonical roof quote workflow * fix: keep migration dry-run idempotent * [verified] feat: complete Smart Pakker management * [verified] fix: ignore blank task dependencies * [verified] fix: align package duplication with schema * [verified] fix: enforce Discord status limits * [verified] fix: link Smart Pakke materials safely * [verified] fix: harden material link review * [verified] feat: improve material matching * fix: scope pitch validation to roof packages * fix: support canonical snapshots on production schema * [verified] fix: hide internal package metadata from PDF * [verified] feat: deliver sales-ready customer PDF * [verified] feat: ship sales-ready PDF with AI overview * [verified] fix: authenticate project list requests * [verified] fix: refresh project-list authentication * [verified] fix: open existing project details * [verified] fix: keep roof components searchable in builder * [verified] fix: expose all Smart Package categories * [verified] fix: authenticate project creation * [verified] feat: make Smart Pakker the universal project flow * [verified] feat: preview Smart Package contents * [verified] test: keep generic release isolated from downpipe work * feat: add first-class Smart Pakke rentals * [verified] feat: add gutter and downpipe smart packages * [verified] fix: prepare six-house gutter quote flow * [verified] fix: open generic quotes without roof geometry * [verified] fix: review generic quotes with authenticated APIs * [verified] fix: calculate generic Smart Package quotes * [verified] fix: return generic calculation breakdown * feat: checkpoint generic signed snapshot validation with red-green tests * feat: complete fail-closed generic quote approval and customer PDF flow * feat: use generic signed snapshot in final review * feat: redesign generic quote final review * fix: harden generic review summaries * feat: add auditable six-house package basis * [verified] feat: finish auditable Smart Pakke UI * [verified] fix: bind auditable quantity and price bases * [verified] fix: keep six-house basis across package versions * [verified] fix: complete smart package discovery management * [verified] fix: simplify composition and generic scope * [verified] test: keep explicit roof contracts fail closed * [verified] fix: harden generic quote snapshots * fix: make generic quote delivery customer safe * [verified] fix: secure package catalog reads * [verified] fix: close workspace provenance blockers * fix: harden customer document language boundary * [verified] fix: secure smart package internal reads * fix: version package child mutations atomically * feat: add generic customer quote text flow * [verified] fix: allow manual customer numbers * [verified] fix: expose optional roof geometry * [verified] fix: rebase hydrated packages after geometry edits * [verified] feat: add free editable site area map * [verified] fix: harden map recovery and geocoding gate * fix: bind map quantities to authoritative geometry * fix: release geocoder lock before dispatch * fix: separate roof and site geometry provenance * fix: revoke stale admin authorization * fix: migrate task geometry basis * fix: make backend CI dependency-complete * ci: seed isolated e2e login account * fix: allow clean database bootstrap * fix: skip indexes for optional tables * test: use canonical mansard geometry in e2e * [verified] fix(auth): enforce live operator boundary * fix: fail close Ordrestyring offer transport * fix(frontend): authenticate customer project requests * fix: align canonical roof type contract * [verified] fix: reconcile legacy package labor safely * [verified] fix: audit site geometry deletion * docs: add PR 31 reviewer guide * docs: synchronize Obsidian vault * docs: sync integrated reviewer guide to Obsidian * ci: seed isolated auth account explicitly * fix: close offer bootstrap and service readiness gaps * fix: authenticate protected package callers * fix: provision initial admin and disable generic send * [verified] fix: close final quote release blockers * [verified] fix: seed gutter packages before deployment --------- Co-authored-by: alexpolo1 <[email protected]> Co-authored-by: Claude Sonnet 5 <[email protected]>
122 lines
4.9 KiB
JavaScript
122 lines
4.9 KiB
JavaScript
const {
|
|
parseNumber,
|
|
isRentalDuplicate,
|
|
parseWorkbook,
|
|
validateExpectedVersions
|
|
} = require('../src/services/smartPackageExcelImportService');
|
|
const XLSX = require('xlsx');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
describe('SmartPackageExcelImportService', () => {
|
|
test.each([
|
|
['1,007.97 kr.', 1007.97],
|
|
['1.007,97 kr.', 1007.97],
|
|
['600.00 kr.', 600],
|
|
['0.20', 0.2],
|
|
[null, 0]
|
|
])('parses Excel number %p', (value, expected) => {
|
|
expect(parseNumber(value)).toBe(expected);
|
|
});
|
|
|
|
test('removes zero-price rental duplicates covered by Byggeplads', () => {
|
|
const buildingSite = [
|
|
{ name: 'Leje af minigraver 1,5-2,0 ton' },
|
|
{ name: 'Mandskabsvogn 4 personer' },
|
|
{ name: 'Køreplader i stål' },
|
|
{ name: 'Leje Affaldscontainer åben 20 m³' }
|
|
];
|
|
|
|
expect(isRentalDuplicate({ name: 'Minigraver 1,8 ton', standardPrice: 0 }, buildingSite)).toBe(true);
|
|
expect(isRentalDuplicate({ name: 'Mandskabsvogn 4 pers.', standardPrice: 0 }, buildingSite)).toBe(true);
|
|
expect(isRentalDuplicate({ name: 'Køreplader stål', standardPrice: 0 }, buildingSite)).toBe(true);
|
|
expect(isRentalDuplicate({ name: '20 m3 åben', standardPrice: 0 }, buildingSite)).toBe(true);
|
|
expect(isRentalDuplicate({ name: '10 m3 åben', standardPrice: 0 }, buildingSite)).toBe(false);
|
|
expect(isRentalDuplicate({ name: '20 m3 lukket', standardPrice: 0 }, buildingSite)).toBe(false);
|
|
});
|
|
|
|
test('keeps priced and non-overlapping rental packages', () => {
|
|
const buildingSite = [{ name: 'Leje af saxlift 6 m' }];
|
|
|
|
expect(isRentalDuplicate({ name: 'Saxlift 6 m', standardPrice: 320 }, buildingSite)).toBe(false);
|
|
expect(isRentalDuplicate({ name: 'Saxlift 10 m', standardPrice: 0 }, buildingSite)).toBe(false);
|
|
});
|
|
|
|
test('maps an unknown workbook layout to canonical SmartPackage fields', () => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-map-'));
|
|
const filePath = path.join(directory, 'custom.xlsx');
|
|
const workbook = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([
|
|
{ Ydelse: 'Opsætning af skur', Gruppe: 'Byggeplads', Pris: '1.250,00', Måleenhed: 'stk.' }
|
|
]), 'Ark 1');
|
|
XLSX.writeFile(workbook, filePath);
|
|
|
|
const parsed = parseWorkbook(filePath, {
|
|
sheets: [{
|
|
sourceSheet: 'Ark 1', targetSheet: 'Byggeplads', columns: [
|
|
{ source: 'Ydelse', target: 'name' },
|
|
{ source: 'Gruppe', target: 'category' },
|
|
{ source: 'Pris', target: 'unitPrice' },
|
|
{ source: 'Måleenhed', target: 'unit' }
|
|
]
|
|
}]
|
|
});
|
|
|
|
expect(parsed.packages).toHaveLength(1);
|
|
expect(parsed.packages[0]).toMatchObject({ name: 'Opsætning af skur', effectivePrice: 1250, unit: 'stk.' });
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
test('rejects mappings without a name column', () => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-map-'));
|
|
const filePath = path.join(directory, 'custom.xlsx');
|
|
const workbook = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([{ Pris: 100 }]), 'Data');
|
|
XLSX.writeFile(workbook, filePath);
|
|
expect(() => parseWorkbook(filePath, {
|
|
sheets: [{ sourceSheet: 'Data', targetSheet: 'Byggeplads', columns: [{ source: 'Pris', target: 'unitPrice' }] }]
|
|
})).toThrow('mangler pakkenavn');
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
test('requires exact versions for every locked package before synchronization writes', () => {
|
|
const packages = [{ id: 7, version: 3 }, { id: 8, version: 5 }];
|
|
expect(() => validateExpectedVersions(packages, { 7: 3 })).toThrow('package 8');
|
|
expect(() => validateExpectedVersions(packages, { 7: 2, 8: 5 }))
|
|
.toThrow(expect.objectContaining({ code: 'SMART_PACKAGE_VERSION_CONFLICT' }));
|
|
expect(() => validateExpectedVersions(packages, { 7: 3, 8: 5 })).not.toThrow();
|
|
});
|
|
|
|
test('preserves the real Excel sheet and row when blank rows occur', () => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-row-'));
|
|
const filePath = path.join(directory, 'custom.xlsx');
|
|
const workbook = XLSX.utils.book_new();
|
|
const sheet = XLSX.utils.aoa_to_sheet([
|
|
['Ydelse', 'Pris'],
|
|
[],
|
|
['Skurvogn', 750]
|
|
]);
|
|
XLSX.utils.book_append_sheet(workbook, sheet, 'Importdata');
|
|
XLSX.writeFile(workbook, filePath);
|
|
|
|
const parsed = parseWorkbook(filePath, {
|
|
sheets: [{
|
|
sourceSheet: 'Importdata',
|
|
targetSheet: 'Udlejning',
|
|
columns: [
|
|
{ source: 'Ydelse', target: 'name' },
|
|
{ source: 'Pris', target: 'standardPrice' }
|
|
]
|
|
}]
|
|
});
|
|
|
|
expect(parsed.packages[0]).toMatchObject({
|
|
sourceSheet: 'Udlejning',
|
|
excelSheet: 'Importdata',
|
|
sourceRow: 3
|
|
});
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
});
|
|
});
|