231 lines
9.0 KiB
JavaScript
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' });
|
|
});
|
|
});
|