Files
tilbudgivern/backend/__tests__/customerProjectsMaterials.test.js
T

231 lines
9.0 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 customerProjectsRoutes = require('../src/routes/customerProjects');
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/api/customer-projects', customerProjectsRoutes);
return app;
};
const authHeader = () => `Bearer ${jwt.sign({ id: 1, username: 'test-user' }, process.env.JWT_ACCESS_SECRET)}`;
describe('customer project material creation routes', () => {
afterEach(() => {
jest.restoreAllMocks();
});
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')
.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')
.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')
.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')
.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')
.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')
.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')
.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' });
});
});