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]>
513 lines
22 KiB
JavaScript
513 lines
22 KiB
JavaScript
const express = require('express');
|
|
const request = require('supertest');
|
|
const jwt = require('jsonwebtoken');
|
|
|
|
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret';
|
|
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret';
|
|
process.env.AUTH_USERNAME = 'test-user';
|
|
|
|
jest.mock('uuid', () => ({ v4: () => 'test-corr-id-uuid' }));
|
|
jest.mock('../src/utils/logger', () => ({
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
debug: jest.fn(),
|
|
logInfo: jest.fn(),
|
|
logError: jest.fn()
|
|
}));
|
|
|
|
const ProjectMaterialService = require('../src/services/projectMaterialService');
|
|
const CustomerProjectService = require('../src/services/customerProjectService');
|
|
const RoofGeometryService = require('../src/services/roofGeometryService');
|
|
const SmartPackageWorkspaceService = require('../src/services/smartPackageWorkspaceService');
|
|
const QuoteRealismService = require('../src/services/quoteRealismService');
|
|
const customerProjectsRoutes = require('../src/routes/customerProjects');
|
|
|
|
const buildApp = () => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/customer-projects', customerProjectsRoutes);
|
|
return app;
|
|
};
|
|
|
|
const authHeader = (username = 'test-user') => `Bearer ${jwt.sign({ id: 1, username }, process.env.JWT_ACCESS_SECRET)}`;
|
|
|
|
describe('customer project material creation routes', () => {
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
test('reads and atomically replaces the Smart Package Lego workspace', async () => {
|
|
const getWorkspace = jest.spyOn(SmartPackageWorkspaceService.prototype, 'getWorkspace')
|
|
.mockResolvedValue({ projectId: 399, version: 2, instances: [] });
|
|
const replaceWorkspace = jest.spyOn(SmartPackageWorkspaceService.prototype, 'replaceWorkspace')
|
|
.mockResolvedValue({ projectId: 399, version: 3, instances: [{ instanceId: 'block-1' }] });
|
|
|
|
const read = await request(buildApp())
|
|
.get('/api/customer-projects/projects/399/smart-package-workspace')
|
|
.set('Authorization', authHeader());
|
|
expect(read.status).toBe(200);
|
|
expect(read.body.workspace.version).toBe(2);
|
|
expect(getWorkspace).toHaveBeenCalledWith(399);
|
|
|
|
const write = await request(buildApp())
|
|
.put('/api/customer-projects/projects/399/smart-package-workspace')
|
|
.set('Authorization', authHeader())
|
|
.send({ expectedVersion: 2, instances: [{ instanceId: 'block-1' }] });
|
|
expect(write.status).toBe(200);
|
|
expect(write.body.workspace.version).toBe(3);
|
|
expect(replaceWorkspace).toHaveBeenCalledWith(
|
|
399,
|
|
expect.objectContaining({ expectedVersion: 2 }),
|
|
{ operator: 'test-user' }
|
|
);
|
|
});
|
|
|
|
test('allows only the configured operator to read a project workspace', async () => {
|
|
const getWorkspace = jest.spyOn(SmartPackageWorkspaceService.prototype, 'getWorkspace')
|
|
.mockResolvedValue({ projectId: 399, version: 2, instances: [] });
|
|
const app = buildApp();
|
|
|
|
const unauthenticated = await request(app)
|
|
.get('/api/customer-projects/projects/399/smart-package-workspace');
|
|
const nonOperator = await request(app)
|
|
.get('/api/customer-projects/projects/399/smart-package-workspace')
|
|
.set('Authorization', authHeader('other-user'));
|
|
const nonOperatorWrite = await request(app)
|
|
.put('/api/customer-projects/projects/399/smart-package-workspace')
|
|
.set('Authorization', authHeader('other-user'))
|
|
.send({ expectedVersion: 2, instances: [] });
|
|
const operator = await request(app)
|
|
.get('/api/customer-projects/projects/399/smart-package-workspace')
|
|
.set('Authorization', authHeader());
|
|
|
|
expect([unauthenticated.status, nonOperator.status, nonOperatorWrite.status, operator.status])
|
|
.toEqual([401, 403, 403, 200]);
|
|
expect(getWorkspace).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('allows only the configured operator to start project validation AI jobs', async () => {
|
|
const projectLookup = jest.spyOn(CustomerProjectService.prototype, 'getProjectWithDetails')
|
|
.mockResolvedValue({ project: { id: 399 } });
|
|
const app = buildApp();
|
|
|
|
const unauthenticated = await request(app)
|
|
.post('/api/customer-projects/projects/399/validate-flow')
|
|
.send({});
|
|
const nonOperator = await request(app)
|
|
.post('/api/customer-projects/projects/399/validate-flow')
|
|
.set('Authorization', authHeader('other-user'))
|
|
.send({});
|
|
|
|
expect([unauthenticated.status, nonOperator.status]).toEqual([401, 403]);
|
|
expect(projectLookup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('enforces authentication and operator access across project route groups', async () => {
|
|
const app = buildApp();
|
|
const routes = [
|
|
['get', '/api/customer-projects/projects'],
|
|
['post', '/api/customer-projects/projects'],
|
|
['get', '/api/customer-projects/projects/399/labor'],
|
|
['post', '/api/customer-projects/projects/399/labor'],
|
|
['get', '/api/customer-projects/projects/399/materials'],
|
|
['post', '/api/customer-projects/projects/399/materials/match-preview'],
|
|
['get', '/api/customer-projects/projects/399/calculation'],
|
|
['post', '/api/customer-projects/projects/399/calculate'],
|
|
['get', '/api/customer-projects/399/quotes'],
|
|
['put', '/api/customer-projects/quotes/44/status'],
|
|
['get', '/api/customer-projects/project-validation/job-44']
|
|
];
|
|
|
|
for (const [method, path] of routes) {
|
|
const unauthenticated = await request(app)[method](path).send({});
|
|
const nonOperator = await request(app)[method](path)
|
|
.set('Authorization', authHeader('other-user'))
|
|
.send({});
|
|
expect(unauthenticated.status).toBe(401);
|
|
expect(nonOperator.status).toBe(403);
|
|
}
|
|
});
|
|
|
|
test('returns a safe legacy-labor classification challenge to the configured operator', async () => {
|
|
jest.spyOn(SmartPackageWorkspaceService.prototype, 'replaceWorkspace').mockRejectedValue(
|
|
Object.assign(new Error('internal labor row'), {
|
|
status: 409,
|
|
code: 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS',
|
|
expectedDigest: 'a'.repeat(64),
|
|
ambiguousEntryIndexes: [0, 2]
|
|
})
|
|
);
|
|
const response = await request(buildApp())
|
|
.put('/api/customer-projects/projects/399/smart-package-workspace')
|
|
.set('Authorization', authHeader())
|
|
.send({ expectedVersion: 0, instances: [] });
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(response.body).toEqual({
|
|
success: false,
|
|
error: 'Eksisterende arbejdstimer kræver eksplicit klassifikation.',
|
|
code: 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS',
|
|
legacyLaborClassification: {
|
|
expectedDigest: 'a'.repeat(64),
|
|
ambiguousEntryIndexes: [0, 2]
|
|
}
|
|
});
|
|
expect(JSON.stringify(response.body)).not.toContain('internal labor row');
|
|
});
|
|
|
|
test('returns a safe workspace conflict without exposing internals', async () => {
|
|
jest.spyOn(SmartPackageWorkspaceService.prototype, 'replaceWorkspace').mockRejectedValue(
|
|
Object.assign(new Error('internal row version 4'), { status: 409, code: 'SMART_PACKAGE_WORKSPACE_CONFLICT' })
|
|
);
|
|
const response = await request(buildApp())
|
|
.put('/api/customer-projects/projects/399/smart-package-workspace')
|
|
.set('Authorization', authHeader())
|
|
.send({ expectedVersion: 2, instances: [] });
|
|
expect(response.status).toBe(409);
|
|
expect(response.body).toEqual({ success: false, error: 'Smart Pakke-arbejdsområdet er ændret. Genindlæs og prøv igen.', code: 'SMART_PACKAGE_WORKSPACE_CONFLICT' });
|
|
});
|
|
|
|
test('returns a safe stale-map conflict without exposing geometry internals', async () => {
|
|
jest.spyOn(SmartPackageWorkspaceService.prototype, 'replaceWorkspace').mockRejectedValue(
|
|
Object.assign(new Error('signature bbbb does not match project 399 row aaaa'), {
|
|
status: 409, code: 'SMART_PACKAGE_SITE_GEOMETRY_STALE'
|
|
})
|
|
);
|
|
const response = await request(buildApp())
|
|
.put('/api/customer-projects/projects/399/smart-package-workspace')
|
|
.set('Authorization', authHeader())
|
|
.send({ expectedVersion: 2, instances: [] });
|
|
expect(response.status).toBe(409);
|
|
expect(response.body).toEqual({
|
|
success: false,
|
|
error: 'Kortområdet mangler eller er ændret. Genåbn kortet og prøv igen.',
|
|
code: 'SMART_PACKAGE_SITE_GEOMETRY_STALE'
|
|
});
|
|
expect(JSON.stringify(response.body)).not.toContain('bbbb');
|
|
});
|
|
|
|
test('forwards roof material from Enhanced Geometry to persistence', async () => {
|
|
const save = jest.spyOn(RoofGeometryService.prototype, 'saveRoofGeometry').mockImplementation(async (_id, geometry) => {
|
|
if (geometry.roofMaterial === 'asbest-ukendt') {
|
|
throw Object.assign(new Error('invalid material'), { status: 400, code: 'ROOF_GEOMETRY_INVALID' });
|
|
}
|
|
return { id: 1 };
|
|
});
|
|
jest.spyOn(RoofGeometryService.prototype, 'getRoofGeometry').mockResolvedValue({ roofType: 'gable' });
|
|
jest.spyOn(CustomerProjectService.prototype, 'updateProjectStatus').mockResolvedValue(true);
|
|
const res = await request(buildApp())
|
|
.post('/api/customer-projects/projects/399/geometry')
|
|
.set('Authorization', authHeader())
|
|
.send({
|
|
roofType: 'skraat_tag',
|
|
roofMaterial: 'tegl',
|
|
roofWidth: 8,
|
|
roofLength: 10,
|
|
roofPitch: 30,
|
|
totalArea: 100
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(save).toHaveBeenCalledWith(399, expect.objectContaining({ roofMaterial: 'tegl' }));
|
|
|
|
const invalid = await request(buildApp())
|
|
.post('/api/customer-projects/projects/399/geometry')
|
|
.set('Authorization', authHeader())
|
|
.send({ roofType: 'skraat_tag', roofMaterial: 'asbest-ukendt', totalArea: 100 });
|
|
expect(invalid.status).toBe(400);
|
|
expect(invalid.body).toMatchObject({
|
|
success: false, error: 'Ugyldige taggeometridata', code: 'ROOF_GEOMETRY_INVALID'
|
|
});
|
|
expect(save).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('returns a safe 400 response for a material name longer than the database column', async () => {
|
|
const error = Object.assign(new Error('Materialenavn må højst være 255 tegn'), {
|
|
status: 400,
|
|
code: 'MATERIAL_NAME_TOO_LONG'
|
|
});
|
|
jest.spyOn(ProjectMaterialService.prototype, 'bulkAddMaterials').mockRejectedValue(error);
|
|
|
|
const res = await request(buildApp())
|
|
.post('/api/customer-projects/projects/394/materials/bulk')
|
|
.set('Authorization', authHeader())
|
|
.send({ materials: [{ materialName: 'x'.repeat(256) }] });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({
|
|
success: false,
|
|
error: 'Materialenavn må højst være 255 tegn',
|
|
code: 'MATERIAL_NAME_TOO_LONG'
|
|
});
|
|
});
|
|
|
|
test('does not expose database error codes in a 500 response', async () => {
|
|
const error = Object.assign(new Error('Data too long for column'), {
|
|
code: 'ER_DATA_TOO_LONG'
|
|
});
|
|
jest.spyOn(ProjectMaterialService.prototype, 'bulkAddMaterials').mockRejectedValue(error);
|
|
|
|
const res = await request(buildApp())
|
|
.post('/api/customer-projects/projects/394/materials/bulk')
|
|
.set('Authorization', authHeader())
|
|
.send({ materials: [{ materialName: 'Taglægte' }] });
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toEqual({
|
|
success: false,
|
|
error: 'Fejl ved bulk tilføjelse af materialer'
|
|
});
|
|
});
|
|
|
|
test('only exposes allowlisted validation details for client errors', async () => {
|
|
const error = Object.assign(new Error('SQL syntax near customer input'), {
|
|
status: 400,
|
|
code: 'ER_PARSE_ERROR'
|
|
});
|
|
jest.spyOn(ProjectMaterialService.prototype, 'bulkAddMaterials').mockRejectedValue(error);
|
|
|
|
const res = await request(buildApp())
|
|
.post('/api/customer-projects/projects/394/materials/bulk')
|
|
.set('Authorization', authHeader())
|
|
.send({ materials: [{ materialName: 'Taglægte' }] });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({
|
|
success: false,
|
|
error: 'Ugyldige materialedata'
|
|
});
|
|
});
|
|
|
|
test('returns the same safe validation contract on the non-bulk route', async () => {
|
|
const error = Object.assign(new Error('Materialenavn må højst være 255 tegn'), {
|
|
status: 400,
|
|
code: 'MATERIAL_NAME_TOO_LONG'
|
|
});
|
|
jest.spyOn(ProjectMaterialService.prototype, 'addProjectMaterial').mockRejectedValue(error);
|
|
jest.spyOn(ProjectMaterialService.prototype, 'bulkAddMaterials').mockRejectedValue(error);
|
|
|
|
const res = await request(buildApp())
|
|
.post('/api/customer-projects/projects/394/materials')
|
|
.set('Authorization', authHeader())
|
|
.send({ materials: [{
|
|
materialName: 'x'.repeat(256),
|
|
quantity: 1,
|
|
unitPrice: 10
|
|
}] });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({
|
|
success: false,
|
|
error: 'Materialenavn må højst være 255 tegn',
|
|
code: 'MATERIAL_NAME_TOO_LONG'
|
|
});
|
|
});
|
|
|
|
test('returns read-only material match suggestions for a project', async () => {
|
|
const suggestions = [{
|
|
projectMaterialId: 501,
|
|
status: 'matched_name',
|
|
score: 0.91,
|
|
candidate: { id: 10, sku: 'TAG-001', name: 'Taglægte 38x73 mm C24' }
|
|
}];
|
|
const preview = jest.spyOn(ProjectMaterialService.prototype, 'previewMaterialMatches')
|
|
.mockResolvedValue(suggestions);
|
|
|
|
const res = await request(buildApp())
|
|
.post('/api/customer-projects/projects/392/materials/match-preview')
|
|
.set('Authorization', authHeader())
|
|
.send({ materialIds: [501] });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, matches: suggestions });
|
|
expect(preview).toHaveBeenCalledWith(392, [501]);
|
|
});
|
|
|
|
test('links only an explicitly confirmed project material candidate', async () => {
|
|
const linked = {
|
|
projectMaterialId: 501,
|
|
materialId: 10,
|
|
name: 'Taglægte 38x73 mm C24',
|
|
unitPrice: 14.5,
|
|
totalPrice: 29
|
|
};
|
|
const link = jest.spyOn(ProjectMaterialService.prototype, 'linkProjectMaterial')
|
|
.mockResolvedValue(linked);
|
|
|
|
const res = await request(buildApp())
|
|
.put('/api/customer-projects/projects/392/materials/501/link')
|
|
.set('Authorization', authHeader())
|
|
.send({ materialId: 10 });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, material: linked });
|
|
expect(link).toHaveBeenCalledWith(392, 501, 10);
|
|
});
|
|
|
|
test('rejects a missing master material id before linking', async () => {
|
|
const link = jest.spyOn(ProjectMaterialService.prototype, 'linkProjectMaterial');
|
|
|
|
const res = await request(buildApp())
|
|
.put('/api/customer-projects/projects/392/materials/501/link')
|
|
.set('Authorization', authHeader())
|
|
.send({});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ success: false, error: 'Materiale-id er påkrævet' });
|
|
expect(link).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('requires authentication on every project rental write route', async () => {
|
|
const app = buildApp();
|
|
const attempts = [
|
|
request(app).post('/api/customer-projects/projects/392/rentals').send({}),
|
|
request(app).post('/api/customer-projects/projects/392/rentals/bulk').send({ rentals: [] }),
|
|
request(app).put('/api/customer-projects/projects/392/rentals').send({ rentals: [], expectedSnapshot: '[]' }),
|
|
request(app).put('/api/customer-projects/projects/392/rentals/44').send({}),
|
|
request(app).delete('/api/customer-projects/projects/392/rentals/44')
|
|
];
|
|
const responses = await Promise.all(attempts);
|
|
expect(responses.map(response => response.status)).toEqual([401, 401, 401, 401, 401]);
|
|
});
|
|
|
|
test('rejects malformed IDs on every legacy rental write route', async () => {
|
|
const app = buildApp();
|
|
const attempts = [
|
|
request(app).post('/api/customer-projects/projects/392abc/rentals').set('Authorization', authHeader()).send({ rentalName: 'X' }),
|
|
request(app).post('/api/customer-projects/projects/392abc/rentals/bulk').set('Authorization', authHeader()).send({ rentals: [] }),
|
|
request(app).put('/api/customer-projects/projects/392/rentals/44xyz').set('Authorization', authHeader()).send({}),
|
|
request(app).delete('/api/customer-projects/projects/392abc/rentals/44').set('Authorization', authHeader())
|
|
];
|
|
const responses = await Promise.all(attempts);
|
|
expect(responses.map(response => response.status)).toEqual([400, 400, 400, 400]);
|
|
});
|
|
|
|
test('rejects malformed project IDs for rental replacement', async () => {
|
|
const replace = jest.spyOn(ProjectMaterialService.prototype, 'replaceProjectRentals');
|
|
const app = buildApp();
|
|
expect((await request(app)
|
|
.put('/api/customer-projects/projects/392abc/rentals')
|
|
.set('Authorization', authHeader())
|
|
.send({ rentals: [] })).status).toBe(400);
|
|
expect(replace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('atomically replaces project rentals with their pricing units', async () => {
|
|
const rentals = [{ rentalName: 'Minigraver', quantity: 2, unit: 'dag', unitPrice: 897.94 }];
|
|
const replace = jest.spyOn(ProjectMaterialService.prototype, 'replaceProjectRentals')
|
|
.mockResolvedValue({ addedRentals: rentals, totalCost: 1795.88, count: 1 });
|
|
|
|
const res = await request(buildApp())
|
|
.put('/api/customer-projects/projects/392/rentals')
|
|
.set('Authorization', authHeader())
|
|
.send({ rentals, expectedSnapshot: '[]' });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ success: true, totalCost: 1795.88, count: 1 });
|
|
expect(replace).toHaveBeenCalledWith(392, rentals, '[]');
|
|
});
|
|
|
|
test('returns the safe rental validation contract', async () => {
|
|
const error = Object.assign(new Error('Udlejning kræver navn, prisgrundlag, positiv mængde og positiv enhedspris'), {
|
|
status: 400,
|
|
code: 'INVALID_RENTAL_LINE'
|
|
});
|
|
jest.spyOn(ProjectMaterialService.prototype, 'replaceProjectRentals').mockRejectedValue(error);
|
|
|
|
const res = await request(buildApp())
|
|
.put('/api/customer-projects/projects/392/rentals')
|
|
.set('Authorization', authHeader())
|
|
.send({ rentals: [{ rentalName: 'Ugyldig' }], expectedSnapshot: '[]' });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ success: false, error: error.message, code: 'INVALID_RENTAL_LINE' });
|
|
});
|
|
|
|
test('blocks finalization and quote generation without current realism approval', async () => {
|
|
const error = Object.assign(new Error('Jannick realismecheck skal godkendes før fast tilbud'), {
|
|
status: 409,
|
|
code: 'REALISM_APPROVAL_REQUIRED',
|
|
blockers: ['døre kræver type']
|
|
});
|
|
jest.spyOn(QuoteRealismService.prototype, 'requireApprovedAnalysis').mockRejectedValue(error);
|
|
const app = buildApp();
|
|
const statusResponse = await request(app)
|
|
.patch('/api/customer-projects/projects/392/status')
|
|
.set('Authorization', authHeader())
|
|
.send({ status: 'ready_for_ordrestyring' });
|
|
const quoteResponse = await request(app)
|
|
.post('/api/customer-projects/projects/392/generate-quote-draft')
|
|
.set('Authorization', authHeader())
|
|
.send({ mode: 'static' });
|
|
expect(statusResponse.status).toBe(409);
|
|
expect(quoteResponse.status).toBe(409);
|
|
expect(statusResponse.body.code).toBe('REALISM_APPROVAL_REQUIRED');
|
|
expect(quoteResponse.body.code).toBe('REALISM_APPROVAL_REQUIRED');
|
|
});
|
|
|
|
test('requires exact canonical snapshot approval for Jannick realism check', async () => {
|
|
const snapshot = { signature: 'abc', approved: true, readyForSubmission: true, artifact: { schema: 'roof_quote_snapshot_v1' } };
|
|
const analysis = { projectId: 392, approved: true, readyForFixedPrice: true };
|
|
const approveSnapshot = jest.spyOn(QuoteRealismService.prototype, 'approveSnapshot').mockResolvedValue(snapshot);
|
|
jest.spyOn(QuoteRealismService.prototype, 'getAnalysis').mockResolvedValue(analysis);
|
|
const payload = {
|
|
signature: 'abc',
|
|
acknowledgedClarifications: ['Dørtype'],
|
|
acceptedBlockers: ['døre kræver type']
|
|
};
|
|
const res = await request(buildApp())
|
|
.post('/api/customer-projects/projects/392/realism-approval')
|
|
.set('Authorization', authHeader())
|
|
.send(payload);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, snapshot, analysis });
|
|
expect(approveSnapshot).toHaveBeenCalledWith(392, 'abc', 'test-user');
|
|
});
|
|
|
|
test('returns only approval status to non-operator users', async () => {
|
|
jest.spyOn(QuoteRealismService.prototype, 'getAnalysis').mockResolvedValue({
|
|
projectId: 392,
|
|
approved: false,
|
|
readyForFixedPrice: false,
|
|
blockers: ['døre kræver type'],
|
|
scopes: [{ key: 'doors' }],
|
|
signature: 'secret-history-signature'
|
|
});
|
|
const otherToken = jwt.sign({ id: 2, username: 'other-user' }, process.env.JWT_ACCESS_SECRET);
|
|
const res = await request(buildApp())
|
|
.get('/api/customer-projects/projects/392/realism-analysis')
|
|
.set('Authorization', `Bearer ${otherToken}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.analysis).toEqual({
|
|
projectId: 392,
|
|
approved: false,
|
|
readyForFixedPrice: false,
|
|
approvalMode: null,
|
|
historyRestricted: true
|
|
});
|
|
});
|
|
|
|
test('returns full realism analysis to the live configured operator', async () => {
|
|
const analysis = {
|
|
projectId: 392,
|
|
confidence: 'low',
|
|
readyForFixedPrice: false,
|
|
blockers: ['døre kræver type'],
|
|
scopes: [{ key: 'doors', unit: 'stk', quantity: 4 }]
|
|
};
|
|
const getAnalysis = jest.spyOn(QuoteRealismService.prototype, 'getAnalysis').mockResolvedValue(analysis);
|
|
const res = await request(buildApp())
|
|
.get('/api/customer-projects/projects/392/realism-analysis')
|
|
.set('Authorization', authHeader());
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true, analysis });
|
|
expect(getAnalysis).toHaveBeenCalledWith(392);
|
|
});
|
|
});
|
|
|
|
// Route fixtures include the live account required by the shared authorization boundary.
|
|
beforeEach(() => {
|
|
jest.spyOn(require('../src/services/userService'), 'findByUsername').mockImplementation(async username => ({ id: 1, username, role: 'admin' }));
|
|
});
|
|
afterEach(() => jest.restoreAllMocks());
|