Files
tilbudgivern/backend/__tests__/smartPackageManagementContract.test.js
T
f37adae2cb
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: deliver auditable Smart Pakke quote flow and free site geometry (#31)
* 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]>
2026-09-26 22:39:18 +02:00

269 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});