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]>
131 lines
6.2 KiB
JavaScript
131 lines
6.2 KiB
JavaScript
const Service = require('../src/services/smartPackageManagementService');
|
|
|
|
jest.mock('../src/utils/logger', () => ({ info: jest.fn(), error: jest.fn() }));
|
|
|
|
const PACKAGE_ID = 7;
|
|
const VERSION = 3;
|
|
|
|
function fixture({ stale = false, failOn = null } = {}) {
|
|
const calls = [];
|
|
const connection = {
|
|
beginTransaction: jest.fn(),
|
|
commit: jest.fn(),
|
|
rollback: jest.fn(),
|
|
release: jest.fn(),
|
|
execute: jest.fn(async (sql, params = []) => {
|
|
calls.push({ sql, params });
|
|
if (failOn && sql.includes(failOn)) throw new Error('child write failed');
|
|
if (sql.includes('FROM smart_package_steps') && sql.includes('JOIN smart_package_tasks')) {
|
|
return [[{ step_id: 31, task_id: 21, package_id: PACKAGE_ID, version: stale ? VERSION + 1 : VERSION }]];
|
|
}
|
|
if (sql.includes('FROM smart_package_tasks') && sql.includes('JOIN material_packages')) {
|
|
return [[{
|
|
task_id: 21,
|
|
package_id: PACKAGE_ID,
|
|
hours: 2,
|
|
version: stale ? VERSION + 1 : VERSION
|
|
}]];
|
|
}
|
|
if (sql.includes('FROM material_packages') && sql.includes('FOR UPDATE')) {
|
|
return [[{ id: PACKAGE_ID, version: stale ? VERSION + 1 : VERSION, is_active: 1 }]];
|
|
}
|
|
if (sql.includes('MAX(task_order)')) return [[{ next_order: 4 }]];
|
|
if (sql.includes('MAX(step_order)')) return [[{ next_order: 5 }]];
|
|
if (sql.includes('INSERT INTO smart_package_tasks')) return [{ insertId: 21, affectedRows: 1 }];
|
|
if (sql.includes('INSERT INTO smart_package_steps')) return [{ insertId: 31, affectedRows: 1 }];
|
|
return [{ affectedRows: 1 }];
|
|
})
|
|
};
|
|
const service = new Service({ pool: { getConnection: async () => connection } });
|
|
service.readManagementResult = jest.fn(async () => ({ id: PACKAGE_ID, version: VERSION + 1, name: 'Current' }));
|
|
return { service, connection, calls };
|
|
}
|
|
|
|
const cases = [
|
|
['add task', (service) => service.addTaskToPackage(PACKAGE_ID, { name: 'Task', hours: 1 }, VERSION)],
|
|
['update task', (service) => service.updateTask(21, { name: 'Renamed', hours: 2, rate: 725, timeUnit: 'per_meter', timePerUnit: 0.25 }, VERSION)],
|
|
['delete task', (service) => service.deleteTask(21, VERSION)],
|
|
['reorder tasks', (service) => service.reorderTasks(PACKAGE_ID, [{ taskId: 21, order: 1 }], VERSION)],
|
|
['add custom task', (service) => service.createCustomTask({ packageId: PACKAGE_ID, name: 'Custom', timeUnit: 'per_meter', timePerUnit: 0.2, expectedVersion: VERSION })],
|
|
['add step', (service) => service.addStepToTask(21, { title: 'Step' }, VERSION)],
|
|
['update step', (service) => service.updateStep(31, { title: 'Renamed' }, VERSION)],
|
|
['delete step', (service) => service.deleteStep(31, VERSION)],
|
|
['reorder steps', (service) => service.reorderSteps(21, [{ stepId: 31, order: 1 }], VERSION)]
|
|
];
|
|
|
|
describe('transactional package child mutations', () => {
|
|
test.each(cases)('%s locks the parent, bumps its version exactly once and returns the current package', async (_name, mutate) => {
|
|
const { service, connection, calls } = fixture();
|
|
|
|
await expect(mutate(service)).resolves.toMatchObject({
|
|
package: { id: PACKAGE_ID, version: VERSION + 1 },
|
|
version: VERSION + 1
|
|
});
|
|
|
|
expect(connection.beginTransaction).toHaveBeenCalledTimes(1);
|
|
expect(connection.commit).toHaveBeenCalledTimes(1);
|
|
expect(connection.rollback).not.toHaveBeenCalled();
|
|
expect(connection.release).toHaveBeenCalledTimes(1);
|
|
expect(calls.some(({ sql }) => sql.includes('FOR UPDATE'))).toBe(true);
|
|
const parentWrites = calls.filter(({ sql }) => /^\s*UPDATE material_packages\b/.test(sql));
|
|
expect(parentWrites).toHaveLength(1);
|
|
expect(parentWrites[0].sql).toContain('version = version + 1');
|
|
});
|
|
|
|
test.each(cases)('%s rejects a stale expectedVersion before any child write', async (_name, mutate) => {
|
|
const { service, connection, calls } = fixture({ stale: true });
|
|
|
|
await expect(mutate(service)).rejects.toMatchObject({
|
|
status: 409,
|
|
code: 'SMART_PACKAGE_VERSION_CONFLICT'
|
|
});
|
|
|
|
expect(connection.rollback).toHaveBeenCalledTimes(1);
|
|
expect(connection.commit).not.toHaveBeenCalled();
|
|
expect(calls.some(({ sql }) => /^(\s*)(INSERT|UPDATE|DELETE) (smart_package_tasks|smart_package_steps)/.test(sql))).toBe(false);
|
|
expect(calls.some(({ sql }) => /^\s*UPDATE material_packages\b/.test(sql))).toBe(false);
|
|
});
|
|
|
|
test('a child write failure rolls the task mutation and parent version back atomically', async () => {
|
|
const { service, connection, calls } = fixture({ failOn: 'UPDATE smart_package_tasks' });
|
|
|
|
await expect(service.updateTask(21, { name: 'Renamed', hours: 2 }, VERSION))
|
|
.rejects.toThrow('child write failed');
|
|
|
|
expect(connection.rollback).toHaveBeenCalledTimes(1);
|
|
expect(connection.commit).not.toHaveBeenCalled();
|
|
expect(calls.some(({ sql }) => /^\s*UPDATE material_packages\b/.test(sql))).toBe(false);
|
|
});
|
|
|
|
test('name, rate, basis and dependency-only task changes still advance the aggregate version', async () => {
|
|
const { service, calls } = fixture();
|
|
|
|
await service.updateTask(21, {
|
|
name: 'Only metadata changed',
|
|
hours: 2,
|
|
rate: 725,
|
|
timeUnit: 'per_meter',
|
|
timePerUnit: 0.25,
|
|
dependsOn: 20
|
|
}, VERSION);
|
|
|
|
expect(calls.filter(({ sql }) => /^\s*UPDATE material_packages\b/.test(sql))).toHaveLength(1);
|
|
});
|
|
|
|
test.each([
|
|
['addTaskToPackage', service => service.addTaskToPackage(PACKAGE_ID, { name: 'Task' })],
|
|
['updateTask', service => service.updateTask(21, { name: 'Task' })],
|
|
['deleteTask', service => service.deleteTask(21)],
|
|
['reorderTasks', service => service.reorderTasks(PACKAGE_ID, [])],
|
|
['createCustomTask', service => service.createCustomTask({ packageId: PACKAGE_ID, name: 'Custom', timeUnit: 'per_meter', timePerUnit: 1 })],
|
|
['addStepToTask', service => service.addStepToTask(21, { title: 'Step' })],
|
|
['updateStep', service => service.updateStep(31, { title: 'Step' })],
|
|
['deleteStep', service => service.deleteStep(31)],
|
|
['reorderSteps', service => service.reorderSteps(21, [])]
|
|
])('%s fails closed when expectedVersion is absent', async (_method, mutate) => {
|
|
const { service, connection } = fixture();
|
|
await expect(mutate(service)).rejects.toMatchObject({ status: 400 });
|
|
expect(connection.beginTransaction).not.toHaveBeenCalled();
|
|
});
|
|
});
|