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]>
269 lines
16 KiB
JavaScript
269 lines
16 KiB
JavaScript
const Service = require('../src/services/smartPackageManagementService');
|
||
jest.mock('../src/utils/logger', () => ({ info: jest.fn(), error: jest.fn() }));
|
||
|
||
const contract = {
|
||
name: 'Tag', description: 'Beskrivelse', package_type: 'rental_service',
|
||
validation_status: 'blocked', is_active: 0, unit: 'm²', unit_price: 10,
|
||
price_per_unit: 11, standard_price: 12, price_source: 'manual',
|
||
price_source_value: 'Prisliste 2026', price_basis_note: 'Ekskl. moms',
|
||
geometry_basis: 'roof_area', geometry_factor: 1.1, default_count: 2,
|
||
default_quantity: 3, replacement_scope: 'full', compatible_roof_materials: ['tegl'],
|
||
allowed_roof_forms: ['gable'], min_pitch_degrees: 20, max_pitch_degrees: 45,
|
||
pitch_verification_status: 'verified', pitch_review_required: false,
|
||
time_per_unit: 0.25,
|
||
materials: [], tasks: []
|
||
};
|
||
|
||
// Stateful SQL fixture: assertions inspect persisted values, not echoed input.
|
||
function fixture(initial = {}) {
|
||
let row = { id: 7, version: 3, name: 'Arkiv', package_type: 'rental_service', validation_status: 'blocked', is_active: 0, ...initial };
|
||
const connection = {
|
||
beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(),
|
||
execute: jest.fn(async (sql, params = []) => {
|
||
if (/SELECT .*FROM material_packages/s.test(sql)) {
|
||
if (sql.includes('is_active = 1') && !row.is_active && row.validation_status !== 'blocked') return [[]];
|
||
return [[{ ...row }]];
|
||
}
|
||
if (/INSERT INTO material_packages/.test(sql)) {
|
||
const columns = sql.match(/material_packages\s*\(([^)]+)\)/)[1].split(',').map(s => s.trim().replace(/`/g, ''));
|
||
row = { id: 8, version: 1, is_active: 1, ...Object.fromEntries(columns.map((key, i) => [key, params[i]])) };
|
||
return [{ insertId: 8 }];
|
||
}
|
||
if (/UPDATE material_packages SET updated_at/.test(sql)) {
|
||
if (params.at(-1) !== row.version) return [{ affectedRows: 0 }];
|
||
row.version++;
|
||
}
|
||
if (/UPDATE material_packages/.test(sql)) {
|
||
const columns = [...sql.split('WHERE')[0].matchAll(/(?:SET |,\s*)(\w+) = \?/g)].map(m => m[1]);
|
||
columns.forEach((key, i) => { row[key] = params[i]; });
|
||
return [{ affectedRows: 1 }];
|
||
}
|
||
if (/SELECT/.test(sql)) return [[]];
|
||
return [{ insertId: 20, affectedRows: 1 }];
|
||
})
|
||
};
|
||
return { service: new Service({ pool: { ...connection, getConnection: async () => connection } }), connection, row: () => row };
|
||
}
|
||
|
||
describe('management package contract regressions', () => {
|
||
test('POST persists and returns the complete contract', async () => {
|
||
const { service } = fixture();
|
||
expect(await service.createPackage(contract)).toMatchObject({ ...contract, validation_status: 'needs_review', is_active: 0, pitch_review_required: 0, id: 8, version: 1 });
|
||
});
|
||
test('PUT edits archived packages without activating them and returns persisted version', async () => {
|
||
const { service } = fixture({ validation_status: 'archived' });
|
||
const result = await service.updatePackage(7, { expectedVersion: 3, name: 'Rettet' });
|
||
expect(result).toMatchObject({ id: 7, name: 'Rettet', version: 4, is_active: 0, validation_status: 'archived' });
|
||
});
|
||
test('PUT cannot reactivate a persisted archived package without a lifecycle transition', async () => {
|
||
const { service, connection } = fixture({ validation_status: 'archived', is_active: 0 });
|
||
await expect(service.updatePackage(7, { expectedVersion: 3, is_active: 1 })).rejects.toMatchObject({ status: 400 });
|
||
expect(connection.commit).not.toHaveBeenCalled();
|
||
});
|
||
test('PUT cannot activate a complete offer without at least one persisted task', async () => {
|
||
const { service, connection } = fixture({
|
||
package_type: 'complete_offer', validation_status: 'needs_review', is_active: 0,
|
||
pitch_verification_status: 'verified', pitch_review_required: 0,
|
||
compatible_roof_materials: '["tegl"]', allowed_roof_forms: '["gable"]',
|
||
min_pitch_degrees: 20, max_pitch_degrees: 45
|
||
});
|
||
const originalExecute = connection.execute.getMockImplementation();
|
||
connection.execute.mockImplementation(async (sql, params) => {
|
||
if (sql.includes('SELECT * FROM package_materials')) return [[{ material_id: 42, material_name: 'Tegl', unit_price: 25 }]];
|
||
if (sql.includes('FROM materials')) return [[{ id: 42, sku: 'SKU-42', name: 'Tegl' }]];
|
||
if (sql.includes('COUNT(*) AS task_count')) return [[{ task_count: 0 }]];
|
||
return originalExecute(sql, params);
|
||
});
|
||
await expect(service.updatePackage(7, {
|
||
expectedVersion: 3, validation_status: 'verified', is_active: 1
|
||
})).rejects.toMatchObject({ status: 400 });
|
||
expect(connection.commit).not.toHaveBeenCalled();
|
||
});
|
||
test('PUT persists the complete contract including explicit lifecycle', async () => {
|
||
const { service } = fixture();
|
||
expect(await service.updatePackage(7, { ...contract, expectedVersion: 3 })).toMatchObject({ ...contract, pitch_review_required: 0, version: 4 });
|
||
});
|
||
test('even an empty PUT checks optimistic version', async () => {
|
||
const { service, connection } = fixture();
|
||
await expect(service.updatePackage(7, { expectedVersion: 2 })).rejects.toMatchObject({ status: 409 });
|
||
expect(connection.commit).not.toHaveBeenCalled();
|
||
});
|
||
test.each([
|
||
{ unit_price: -1 }, { geometry_factor: 'bad' }, { default_count: -1 },
|
||
{ is_active: 'false' }, { validation_status: 'bogus' }, { package_type: 'bogus' },
|
||
{ materials: {} }, { tasks: [null] }, { compatible_roof_materials: 'tegl' },
|
||
{ pitch_verification_status: 'bogus' }, { min_pitch_degrees: 50, max_pitch_degrees: 20 },
|
||
{ compatible_roof_materials: [] }, { allowed_roof_forms: [] },
|
||
{ min_pitch_degrees: null }, { pitch_review_required: true }
|
||
])('rejects invalid contract before persistence: %j', async patch => {
|
||
const { service, connection } = fixture();
|
||
await expect(service.createPackage({ ...contract, ...patch })).rejects.toMatchObject({ status: 400 });
|
||
expect(connection.commit).not.toHaveBeenCalled();
|
||
});
|
||
test.each([-1, 'bad'])('rejects invalid task timePerUnit alias: %p', async timePerUnit => {
|
||
const { service, connection } = fixture();
|
||
await expect(service.createPackage({
|
||
...contract,
|
||
tasks: [{ name: 'Alias task', hours: 1, timeUnit: 'per_meter', timePerUnit }]
|
||
})).rejects.toMatchObject({ status: 400 });
|
||
expect(connection.commit).not.toHaveBeenCalled();
|
||
});
|
||
test('active verified roof packages require verified cleared pitch evidence', () => {
|
||
const { service } = fixture();
|
||
expect(() => service.managementContract({
|
||
...contract,
|
||
package_type: 'complete_offer',
|
||
validation_status: 'verified',
|
||
is_active: 1,
|
||
pitch_verification_status: 'unverified',
|
||
pitch_review_required: true
|
||
})).toThrow(/pitch|hældning|verified/i);
|
||
});
|
||
|
||
test('PUT cannot erase compatibility while retaining verified pitch', async () => {
|
||
const { service } = fixture(contract);
|
||
await expect(service.updatePackage(7, { expectedVersion: 3, allowed_roof_forms: [] })).rejects.toMatchObject({ status: 400 });
|
||
});
|
||
});
|
||
|
||
describe('atomic duplication', () => {
|
||
function cloneFixture(fail = false) {
|
||
const source = { id: 7, catalog_key: 'unique-key', version: 3, name: 'Arkiv', is_active: 0,
|
||
validation_status: 'archived', validated_at: new Date(), created_by: 'original', validation_notes: 'Original audit' };
|
||
const f = fixture(source);
|
||
const original = f.connection.execute.getMockImplementation();
|
||
f.connection.execute.mockImplementation(async (sql, params) => {
|
||
if (sql.includes('SELECT * FROM smart_package_tasks')) return [[{ id: 10, package_id: 7, name: 'Task', depends_on: null }, { id: 11, package_id: 7, name: 'Next', depends_on: 10 }]];
|
||
if (sql.includes('SELECT * FROM smart_package_steps')) return [[{ id: 12, task_id: 10, title: 'Step' }]];
|
||
if (sql.includes('SELECT * FROM package_materials')) return [[{ id: 13, package_id: 7, material_id: null, material_name: 'Legacy', geometry_multiplier: 'eaves' }]];
|
||
if (fail && sql.includes('INSERT INTO smart_package_steps')) throw new Error('child failure');
|
||
return original(sql, params);
|
||
});
|
||
f.service.readManagementResult = jest.fn(async () => f.row());
|
||
return f;
|
||
}
|
||
test('clones archived identity and children, resets approval, and audits the source', async () => {
|
||
const { service, connection } = cloneFixture();
|
||
const result = await service.duplicatePackage(7, {}, 'operator');
|
||
expect(result).toMatchObject({ id: 8, name: 'Arkiv (kopi)', version: 1, is_active: 0, validation_status: 'needs_review', validated_at: null, catalog_key: null });
|
||
expect(result.created_by).toBe('operator');
|
||
expect(result.validation_notes).toContain('7');
|
||
expect(result.validation_notes).toContain('3');
|
||
expect(result.validation_notes).toContain('Original audit');
|
||
const writes = connection.execute.mock.calls.filter(([sql]) => sql.includes('INSERT INTO'));
|
||
expect(writes.some(([sql, values]) => sql.includes('package_materials') && values.includes('eaves'))).toBe(true);
|
||
expect(writes.some(([sql]) => sql.includes('smart_package_steps'))).toBe(true);
|
||
expect(connection.execute.mock.calls.some(([sql, values]) => sql.includes('UPDATE smart_package_tasks SET depends_on') && values[0] === 20)).toBe(true);
|
||
expect(connection.commit).toHaveBeenCalledTimes(1);
|
||
});
|
||
test('rolls back every clone write if a child fails', async () => {
|
||
const { service, connection } = cloneFixture(true);
|
||
await expect(service.duplicatePackage(7, { name: 'Ny' }, 'operator')).rejects.toThrow('child failure');
|
||
expect(connection.rollback).toHaveBeenCalledTimes(1);
|
||
expect(connection.commit).not.toHaveBeenCalled();
|
||
expect(connection.release).toHaveBeenCalledTimes(1);
|
||
});
|
||
test('rejects overrides other than name at the service boundary', async () => {
|
||
const { service } = cloneFixture();
|
||
await expect(service.duplicatePackage(7, { version: 99 }, 'operator')).rejects.toMatchObject({ status: 400 });
|
||
});
|
||
});
|
||
|
||
describe('management contract edge cases', () => {
|
||
test('creation retains validation notes for pitch and lifecycle audit', async () => {
|
||
const { service } = fixture();
|
||
expect(await service.createPackage({ ...contract, validation_notes: 'Manufacturer clearance: 20–45 degrees' }))
|
||
.toMatchObject({ validation_notes: 'Manufacturer clearance: 20–45 degrees' });
|
||
});
|
||
test.each(['create', 'update'])('%s retains material and task calculation metadata', async action => {
|
||
const { service, connection } = fixture();
|
||
const data = { ...contract, expectedVersion: 3,
|
||
materials: [{ name: 'Material', quantity: 2, unit_price: 10, material_category: 'Tag',
|
||
geometry_multiplier: 'eaves', base_quantity: 1.5, waste_factor: 1.1,
|
||
excel_source_sheet: 'Tag', excel_source_row: 22, source_line_order: 4,
|
||
raw_line: 'original material row', item_code: 'MAT-22', quantity_text: '2 stk',
|
||
unit_price_text: '10,00', price_note: 'Leverandørpris', excel_raw_data: '{"row":22}' }],
|
||
tasks: [{ name: 'Task', hours: 2, time_unit: 'per_meter', time_per_unit: 0.2,
|
||
geometry_basis: 'eaves', price_basis_note: 'Timer pr. meter', is_custom: 1,
|
||
material_varenr: 'MAT-22', excel_source_sheet: 'Tag', excel_source_row: 23,
|
||
source_line_order: 5, raw_line: 'original task row', excel_raw_data: '{"row":23}' }] };
|
||
await (action === 'create' ? service.createPackage(data) : service.updatePackage(7, data));
|
||
const sql = connection.execute.mock.calls.map(call => call[0]).join('\n');
|
||
expect(sql).toContain('geometry_multiplier');
|
||
expect(sql).toContain('time_per_unit');
|
||
expect(sql).toContain('item_code');
|
||
expect(sql).toContain('unit_price_text');
|
||
expect(sql).toContain('is_custom');
|
||
expect(sql).toContain('material_varenr');
|
||
expect(sql).toContain('excel_raw_data');
|
||
expect(connection.execute.mock.calls.some(([, values]) => values?.includes('eaves'))).toBe(true);
|
||
expect(connection.execute.mock.calls.some(([, values]) => values?.includes('original material row'))).toBe(true);
|
||
expect(connection.execute.mock.calls.some(([, values]) => values?.includes('original task row'))).toBe(true);
|
||
});
|
||
test('legacy dependencies belong to the copy without treating the global component catalog as a package relation', async () => {
|
||
const { service, connection } = fixture();
|
||
const original = connection.execute.getMockImplementation();
|
||
connection.execute.mockImplementation(async (sql, values) => {
|
||
if (sql.includes('SELECT * FROM package_tasks')) return [[{ id: 14, package_id: 7, task_name: 'A', depends_on_task_id: null }, { id: 15, package_id: 7, task_name: 'B', depends_on_task_id: 14 }]];
|
||
return original(sql, values);
|
||
});
|
||
service.readManagementResult = jest.fn(async () => ({}));
|
||
await service.duplicatePackage(7, {}, 'operator');
|
||
expect(connection.execute.mock.calls.some(([sql, values]) => sql.includes('UPDATE package_tasks SET depends_on_task_id') && values[0] === 20)).toBe(true);
|
||
expect(connection.execute.mock.calls.some(([sql]) => sql.includes('smart_package_components') && sql.includes('parent_package_id'))).toBe(false);
|
||
});
|
||
test('changing rental to a material package validates its existing material links', async () => {
|
||
const { service, connection } = fixture();
|
||
await expect(service.updatePackage(7, { expectedVersion: 3, package_type: 'component' })).rejects.toMatchObject({ status: 400 });
|
||
expect(connection.commit).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('round-trip safeguards', () => {
|
||
test('retaining verified pitch rejects invalid stored evidence', async () => {
|
||
const { service } = fixture({ ...contract, min_pitch_degrees: 'not-a-number' });
|
||
await expect(service.updatePackage(7, { expectedVersion: 3, name: 'Edit' })).rejects.toMatchObject({ status: 400 });
|
||
});
|
||
test.each(['create', 'update'])('%s remaps task dependencies and accepts task timing aliases', async action => {
|
||
const { service, connection } = fixture();
|
||
const data = { ...contract, expectedVersion: 3, tasks: [
|
||
{ id: 30, name: 'First', hours: 1, timeUnit: 'per_meter', timePerUnit: 0.2 },
|
||
{ id: 31, name: 'Second', hours: 1, depends_on: 30 }
|
||
] };
|
||
await (action === 'create' ? service.createPackage(data) : service.updatePackage(7, data));
|
||
expect(connection.execute.mock.calls.some(([sql, values]) => sql.includes('UPDATE smart_package_tasks SET depends_on') && values[0] === 20)).toBe(true);
|
||
expect(connection.execute.mock.calls.some(([sql, values]) => sql.includes('time_per_unit') && values.includes(0.2))).toBe(true);
|
||
});
|
||
test.each(['create', 'update'])('%s ignores blank task dependencies from the UI', async action => {
|
||
const { service } = fixture();
|
||
const data = {
|
||
...contract,
|
||
expectedVersion: 3,
|
||
tasks: [{ id: 30, name: 'Independent task', hours: 1, depends_on: '' }]
|
||
};
|
||
await expect(action === 'create' ? service.createPackage(data) : service.updatePackage(7, data)).resolves.toBeDefined();
|
||
});
|
||
test('project geometry calculation rejects archived packages at the database boundary', async () => {
|
||
const { service, connection } = fixture({ validation_status: 'archived', is_active: 0 });
|
||
await expect(service.calculatePackageMaterialsWithGeometry(7, { total_area: 100 }, 20))
|
||
.rejects.toMatchObject({ status: 404, code: 'SMART_PACKAGE_NOT_FOUND' });
|
||
expect(connection.execute.mock.calls[0][0]).toMatch(/WHERE id = \? AND is_active = 1/);
|
||
});
|
||
test('project geometry calculation rejects an active roof package with unverified pitch', async () => {
|
||
const { service } = fixture({
|
||
package_type: 'complete_offer', validation_status: 'verified', is_active: 1,
|
||
pitch_verification_status: 'unverified', pitch_review_required: 1,
|
||
compatible_roof_materials: '["tegl"]', allowed_roof_forms: '["gable"]',
|
||
min_pitch_degrees: 20, max_pitch_degrees: 45
|
||
});
|
||
await expect(service.calculatePackageMaterialsWithGeometry(7, { total_area: 100 }, 20))
|
||
.rejects.toMatchObject({ status: 404, code: 'SMART_PACKAGE_NOT_FOUND' });
|
||
});
|
||
test('management list returns the price basis note', async () => {
|
||
const { service, connection } = fixture();
|
||
connection.execute.mockResolvedValue([[]]);
|
||
await service.getPackages({ includeInactive: true });
|
||
expect(connection.execute.mock.calls[0][0]).toContain('mp.price_basis_note');
|
||
});
|
||
});
|