Files
tilbudgivern/backend/__tests__/customerProjectsMaterials.test.js
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

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());