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]>
1001 lines
48 KiB
JavaScript
1001 lines
48 KiB
JavaScript
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'offers-test-access-secret';
|
|
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'offers-test-refresh-secret';
|
|
process.env.AUTH_USERNAME = 'configured-operator';
|
|
|
|
jest.mock('../src/services/graphqlClient', () => ({ request: jest.fn() }));
|
|
|
|
const express = require('express');
|
|
const request = require('supertest');
|
|
const jwt = require('jsonwebtoken');
|
|
const graphqlClient = require('../src/services/graphqlClient');
|
|
const userService = require('../src/services/userService');
|
|
const { createOffersRouter } = require('../routes/offers');
|
|
const findByUsername = jest.spyOn(userService, 'findByUsername');
|
|
|
|
beforeEach(() => {
|
|
findByUsername.mockReset().mockResolvedValue({
|
|
id: 1,
|
|
username: process.env.AUTH_USERNAME,
|
|
role: 'admin'
|
|
});
|
|
});
|
|
|
|
const authHeader = (username = process.env.AUTH_USERNAME) => (
|
|
`Bearer ${jwt.sign({ id: 1, username }, process.env.JWT_ACCESS_SECRET)}`
|
|
);
|
|
|
|
const canonicalSnapshot = (overrides = {}) => {
|
|
const snapshot = {
|
|
signature: 'sig-1',
|
|
artifact: {
|
|
customerProject: {
|
|
id: 7,
|
|
project_name: 'Tagrenovering',
|
|
project_description: 'Udskiftning af tag',
|
|
customer_number: 'C100',
|
|
customer_name: 'Mikael Holck',
|
|
customer_email: '[email protected]',
|
|
customer_phone: '42468110',
|
|
customer_address: 'Hornumvej 7, 4600 Køge'
|
|
},
|
|
lines: {
|
|
materials: [{ name: 'B7 plader', quantity: 0, unit: 'plade', unitPrice: 100, lineTotal: 0 }],
|
|
tasks: [{ description: 'Montering', totalHours: 2, timeUnit: 'timer', rate: 500, lineTotal: 1000 }],
|
|
rentals: [{ name: 'Stillads', quantity: 1, unit: 'uge', unitPrice: 200, lineTotal: 200 }],
|
|
references: [{ name: 'Affald', quantity: 1, unit: 'læs', unitPrice: 100, lineTotal: 100 }]
|
|
},
|
|
economics: {
|
|
materialTotal: 0,
|
|
laborTotal: 1000,
|
|
rentalTotal: 200,
|
|
referenceTotal: 100,
|
|
subtotal: 1300,
|
|
overheadAmount: 195,
|
|
profitAmount: 299,
|
|
totalExclVat: 1794,
|
|
vatAmount: 448.5,
|
|
totalInclVat: 2242.5
|
|
},
|
|
quoteText: 'Kanonisk tilbudstekst'
|
|
},
|
|
readiness: { ready: true },
|
|
approval: { approved: true }
|
|
};
|
|
return { ...snapshot, ...overrides };
|
|
};
|
|
|
|
const createAtomicOperationStore = (persisted = new Map()) => ({
|
|
persisted,
|
|
renew: jest.fn(async () => {}),
|
|
claim: jest.fn(async (key, initialState, { ownerId }) => {
|
|
const existing = persisted.get(key);
|
|
if (!existing) {
|
|
const claimed = { ...initialState, leaseOwner: ownerId };
|
|
persisted.set(key, claimed);
|
|
return { acquired: true, state: claimed };
|
|
}
|
|
if (!existing.leaseOwner && existing.status !== 'completed') {
|
|
const claimed = { ...existing, leaseOwner: ownerId };
|
|
persisted.set(key, claimed);
|
|
return { acquired: true, state: claimed };
|
|
}
|
|
return { acquired: false, state: existing };
|
|
}),
|
|
get: jest.fn(async key => persisted.get(key)),
|
|
set: jest.fn(async (key, state, { ownerId } = {}) => {
|
|
const existing = persisted.get(key);
|
|
if (ownerId && existing?.leaseOwner !== ownerId) {
|
|
const error = new Error('operation lease lost');
|
|
error.code = 'ORDRESTYRING_OPERATION_LEASE_LOST';
|
|
throw error;
|
|
}
|
|
const terminal = ['completed', 'failed', 'compensated'].includes(state.status);
|
|
const saved = { ...state, leaseOwner: terminal ? null : (ownerId || existing?.leaseOwner) };
|
|
persisted.set(key, saved);
|
|
return saved;
|
|
})
|
|
});
|
|
|
|
let quoteRealismService;
|
|
|
|
const buildApp = (roofQuoteSnapshotService, options = {}) => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/ordrestyring/offers', createOffersRouter({
|
|
graphqlClient,
|
|
roofQuoteSnapshotService,
|
|
quoteRealismService: options.quoteRealismService || quoteRealismService,
|
|
operationStateStore: options.operationStateStore || createAtomicOperationStore(),
|
|
operationWaitTimeoutMs: options.operationWaitTimeoutMs || 500,
|
|
operationPollIntervalMs: options.operationPollIntervalMs || 2,
|
|
...options
|
|
}));
|
|
return app;
|
|
};
|
|
|
|
const successfulGraphql = () => {
|
|
graphqlClient.request
|
|
.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } })
|
|
.mockImplementationOnce((query, variables) => ({
|
|
createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line }))
|
|
}));
|
|
};
|
|
|
|
describe('Offers route canonical boundary', () => {
|
|
let roofQuoteSnapshotService;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
graphqlClient.request.mockReset();
|
|
delete graphqlClient.deleteOffer;
|
|
delete graphqlClient.getOfferLines;
|
|
delete graphqlClient.getOfferTotals;
|
|
delete graphqlClient.verifyPersistedLines;
|
|
quoteRealismService = {
|
|
requireApprovedAnalysis: jest.fn().mockResolvedValue({ approved: true, readyForFixedPrice: true })
|
|
};
|
|
roofQuoteSnapshotService = {
|
|
buildFromProject: jest.fn().mockResolvedValue(canonicalSnapshot()),
|
|
assertExpectedSignature: jest.fn().mockResolvedValue(undefined),
|
|
markSent: jest.fn()
|
|
};
|
|
});
|
|
|
|
test('default router construction cannot reach the Ordrestyring transport', async () => {
|
|
graphqlClient.request.mockResolvedValue({ createCustomer: { id: 42 } });
|
|
graphqlClient.deleteOffer = jest.fn().mockResolvedValue({ id: 88, deleted: true });
|
|
graphqlClient.updateOffer = jest.fn().mockResolvedValue({ id: 88 });
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/ordrestyring/offers', createOffersRouter({
|
|
roofQuoteSnapshotService,
|
|
quoteRealismService,
|
|
operationStateStore: createAtomicOperationStore()
|
|
}));
|
|
|
|
const response = await request(app)
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(response.body.code).toBe('ORDRESTYRING_TRANSPORT_NOT_INJECTED');
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
expect(graphqlClient.updateOffer).not.toHaveBeenCalled();
|
|
expect(graphqlClient.deleteOffer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects an unauthenticated request before loading a snapshot', async () => {
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects an authenticated non-operator before loading a snapshot', async () => {
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader('another-user'))
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
['deleted', null, 403],
|
|
['demoted', { id: 1, username: process.env.AUTH_USERNAME, role: 'user' }, 403]
|
|
])('rejects a configured-username JWT for a %s live account', async (_label, current, status) => {
|
|
userService.findByUsername.mockResolvedValue(current);
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(response.status).toBe(status);
|
|
expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('fails closed before loading a quote when live account lookup is unavailable', async () => {
|
|
userService.findByUsername.mockRejectedValue(new Error('database unavailable'));
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(response.status).toBe(503);
|
|
expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('accepts only projectId and expectedSnapshotSignature for a canonical submission', async () => {
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1', totals: { total: 1 } });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects a stale expected signature before any Ordrestyring mutation', async () => {
|
|
const stale = Object.assign(new Error('Snapshot signature mismatch'), {
|
|
status: 409,
|
|
code: 'SNAPSHOT_SIGNATURE_MISMATCH'
|
|
});
|
|
roofQuoteSnapshotService.assertExpectedSignature.mockRejectedValue(stale);
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'stale' });
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(response.body.code).toBe('SNAPSHOT_SIGNATURE_MISMATCH');
|
|
expect(roofQuoteSnapshotService.buildFromProject).toHaveBeenCalledWith(7);
|
|
expect(roofQuoteSnapshotService.assertExpectedSignature)
|
|
.toHaveBeenCalledWith(expect.any(Object), 'stale');
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
['unready', { readiness: { ready: false } }, 'SNAPSHOT_NOT_READY'],
|
|
['unapproved', { approval: { approved: false } }, 'SNAPSHOT_NOT_APPROVED']
|
|
])('rejects an %s server snapshot', async (_label, patch, code) => {
|
|
roofQuoteSnapshotService.buildFromProject.mockResolvedValue(canonicalSnapshot(patch));
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(422);
|
|
expect(response.body.code).toBe(code);
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects changed realism immediately before claiming idempotency or mutating Ordrestyring', async () => {
|
|
const stale = Object.assign(new Error('Realismegodkendelsen er ændret'), {
|
|
status: 409,
|
|
code: 'REALISM_APPROVAL_STALE'
|
|
});
|
|
quoteRealismService.requireApprovedAnalysis.mockRejectedValue(stale);
|
|
const operationStateStore = createAtomicOperationStore();
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(response.body.code).toBe('REALISM_APPROVAL_STALE');
|
|
expect(quoteRealismService.requireApprovedAnalysis).toHaveBeenCalledWith(7, 'sig-1');
|
|
expect(operationStateStore.claim).not.toHaveBeenCalled();
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('includes every signed reservation in the Ordrestyring offer remark', async () => {
|
|
const snapshot = canonicalSnapshot();
|
|
snapshot.artifact.reservations = [
|
|
{ id: 'weather', text: 'Arbejdet forudsætter tørvejr.' },
|
|
'Skjulte rådskader er ikke inkluderet.'
|
|
];
|
|
roofQuoteSnapshotService.buildFromProject.mockResolvedValue(snapshot);
|
|
successfulGraphql();
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(200);
|
|
const offerInput = graphqlClient.request.mock.calls[1][1].input;
|
|
expect(offerInput.remark).toContain('Kanonisk tilbudstekst');
|
|
expect(offerInput.remark).toContain('Arbejdet forudsætter tørvejr.');
|
|
expect(offerInput.remark).toContain('Skjulte rådskader er ikke inkluderet.');
|
|
expect(offerInput.remark).not.toContain('[object Object]');
|
|
});
|
|
|
|
test('uses only the recomputed snapshot, preserves zero quantities and units, and sends all categories exactly', async () => {
|
|
successfulGraphql();
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({ success: true, offerId: 88, lineCount: 6 });
|
|
|
|
const inputs = graphqlClient.request.mock.calls[2][1].inputs;
|
|
expect(inputs.map(line => line.productNumber)).toEqual([
|
|
'MATERIAL', 'LABOR', 'RENTAL', 'REFERENCE_SERVICE', 'OVERHEAD', 'PROFIT'
|
|
]);
|
|
expect(inputs.map(line => line.unit)).toEqual(['plade', 'timer', 'uge', 'læs', 'sum', 'sum']);
|
|
expect(inputs[0].quantity).toBe(0);
|
|
expect(inputs.reduce((sum, line) => sum + Math.round(line.quantity * line.salesPrice * 100), 0))
|
|
.toBe(179400);
|
|
expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects sub-cent price drift that changes extended line economics', async () => {
|
|
const snapshot = canonicalSnapshot();
|
|
snapshot.artifact.lines.tasks[0] = { description: 'Montering', totalHours: 100000, rate: 0.01, lineTotal: 1000 };
|
|
roofQuoteSnapshotService.buildFromProject.mockResolvedValue(snapshot);
|
|
graphqlClient.request.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88 } })
|
|
.mockImplementationOnce((query, { inputs }) => ({ createOfferLines: inputs.map((line, i) => ({
|
|
...line, id: i + 1, salesPrice: line.salesPrice + (i === 1 ? 0.000001 : 0)
|
|
})) }));
|
|
const response = await request(buildApp(roofQuoteSnapshotService)).post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(response.body.code).toBe('LINE_RECONCILIATION_FAILED');
|
|
});
|
|
|
|
test('rejects remote aggregate VAT totals that disagree with the approved snapshot', async () => {
|
|
successfulGraphql();
|
|
graphqlClient.getOfferTotals = jest.fn().mockResolvedValue({ salesPrice: 1794, salesPriceWithVat: 2803.13 });
|
|
const response = await request(buildApp(roofQuoteSnapshotService)).post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(response.body.code).toBe('OFFER_TOTAL_RECONCILIATION_FAILED');
|
|
});
|
|
|
|
test.each([[false, 'customer'], [true, 'customer'], [false, 'offer'], [true, 'offer'], [false, 'lines'], [true, 'lines']])(
|
|
'renews throughout a slow remote mutation and stops on lease loss=%s during %s', async (lost, phase) => {
|
|
const store = createAtomicOperationStore();
|
|
let slowMutation = false;
|
|
store.renew = jest.fn(async () => {
|
|
if (lost && slowMutation) throw Object.assign(new Error('lost'), {
|
|
code: 'ORDRESTYRING_OPERATION_LEASE_LOST', status: 503
|
|
});
|
|
});
|
|
graphqlClient.request.mockImplementation(async (query, variables) => {
|
|
const currentPhase = query.includes('CreateCustomer') ? 'customer' : query.includes('mutation CreateOffer(') ? 'offer' : 'lines';
|
|
if (currentPhase === phase) {
|
|
slowMutation = true;
|
|
await new Promise(resolve => setTimeout(resolve, 65));
|
|
slowMutation = false;
|
|
}
|
|
if (query.includes('CreateCustomer')) {
|
|
return { createCustomer: { id: 42 } };
|
|
}
|
|
if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88 } };
|
|
return { createOfferLines: variables.inputs.map((line, i) => ({ ...line, id: i + 1 })) };
|
|
});
|
|
graphqlClient.deleteOffer = jest.fn();
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore: store, operationLeaseMs: 30 }))
|
|
.post('/api/ordrestyring/offers/create').set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(store.renew.mock.calls.length).toBeGreaterThan(1);
|
|
expect(response.status).toBe(lost ? 503 : 200);
|
|
if (lost) {
|
|
expect(graphqlClient.request).toHaveBeenCalledTimes({ customer: 1, offer: 2, lines: 3 }[phase]);
|
|
expect(graphqlClient.deleteOffer).not.toHaveBeenCalled();
|
|
expect(store.persisted.get('7:sig-1').status).toBe(`${phase}_creating`);
|
|
}
|
|
});
|
|
|
|
test('rejects irreconcilable VAT before creating a remote customer or offer', async () => {
|
|
const snapshot = canonicalSnapshot();
|
|
snapshot.artifact.economics.vatAmount = 0;
|
|
snapshot.artifact.economics.totalInclVat = snapshot.artifact.economics.totalExclVat;
|
|
roofQuoteSnapshotService.buildFromProject.mockResolvedValue(snapshot);
|
|
successfulGraphql();
|
|
const response = await request(buildApp(roofQuoteSnapshotService)).post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(response.status).toBe(422);
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('never resumes an offer after a deletion timeout', async () => {
|
|
const store = createAtomicOperationStore();
|
|
graphqlClient.request.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88 } }).mockResolvedValueOnce({ createOfferLines: [] });
|
|
graphqlClient.deleteOffer = jest.fn().mockRejectedValue(new Error('timeout'));
|
|
const app = buildApp(roofQuoteSnapshotService, { operationStateStore: store });
|
|
const submit = () => request(app).post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
await submit();
|
|
const calls = graphqlClient.request.mock.calls.length;
|
|
const response = await submit();
|
|
expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STATE_UNCERTAIN');
|
|
expect(graphqlClient.request).toHaveBeenCalledTimes(calls);
|
|
expect(graphqlClient.deleteOffer).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('checks the exposed VAT aggregate even when net and gross match', async () => {
|
|
successfulGraphql();
|
|
graphqlClient.getOfferTotals = jest.fn().mockResolvedValue({ salesPrice: 1794, salesPriceWithVat: 2242.5, vat: 0 });
|
|
const response = await request(buildApp(roofQuoteSnapshotService)).post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(response.body.code).toBe('OFFER_TOTAL_RECONCILIATION_FAILED');
|
|
});
|
|
|
|
test.each(['customer_creating', 'offer_creating', 'compensation_pending'])(
|
|
'retains unknown state across repeated attempts from %s', async status => {
|
|
const store = createAtomicOperationStore(new Map([['7:sig-1', {
|
|
status, projectId: 7, snapshotSignature: 'sig-1', createdLines: [], leaseOwner: null
|
|
}]]));
|
|
const app = buildApp(roofQuoteSnapshotService, { operationStateStore: store });
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const response = await request(app).post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STATE_UNCERTAIN');
|
|
}
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
}
|
|
);
|
|
|
|
test('refreshes persisted lines before recovery when the client supports authoritative readback', async () => {
|
|
const { buildOrdrestyringOfferLines } = require('../src/services/ordrestyringOfferNormalizationService');
|
|
const lines = buildOrdrestyringOfferLines(canonicalSnapshot(), { offerId: 88 }).map((line, i) => ({ ...line, id: i + 1 }));
|
|
const store = createAtomicOperationStore(new Map([['7:sig-1', {
|
|
status: 'failed', customerId: 42, offer: { id: 88 }, offerId: 88, createdLines: lines, leaseOwner: null
|
|
}]]));
|
|
graphqlClient.verifyPersistedLines = true;
|
|
graphqlClient.getOfferLines = jest.fn().mockResolvedValue([...lines, { ...lines[0], id: 99 }]);
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore: store }))
|
|
.post('/api/ordrestyring/offers/create').set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(response.body.code).toBe('LINE_RECONCILIATION_FAILED');
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('reuses the completed project-and-snapshot operation for a duplicate request', async () => {
|
|
successfulGraphql();
|
|
const app = buildApp(roofQuoteSnapshotService);
|
|
const payload = { projectId: 7, expectedSnapshotSignature: 'sig-1' };
|
|
|
|
const first = await request(app)
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send(payload);
|
|
const duplicate = await request(app)
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send(payload);
|
|
|
|
expect(first.status).toBe(200);
|
|
expect(duplicate.status).toBe(200);
|
|
expect(duplicate.body).toMatchObject({ success: true, offerId: 88, idempotentReplay: true });
|
|
expect(graphqlClient.request).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
test('coalesces concurrent duplicate requests before creating customer or offer twice', async () => {
|
|
graphqlClient.request.mockImplementation(async (query, variables) => {
|
|
if (query.includes('mutation CreateCustomer')) {
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
return { createCustomer: { id: 42 } };
|
|
}
|
|
if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88, number: 'T-1001' } };
|
|
return { createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line })) };
|
|
});
|
|
const app = buildApp(roofQuoteSnapshotService);
|
|
const submit = () => request(app)
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
const [first, duplicate] = await Promise.all([submit(), submit()]);
|
|
|
|
expect(first.status).toBe(200);
|
|
expect(duplicate.status).toBe(200);
|
|
expect([first.body.offerId, duplicate.body.offerId]).toEqual([88, 88]);
|
|
expect(graphqlClient.request).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
test('reuses completed state persisted by another router instance', async () => {
|
|
successfulGraphql();
|
|
const persisted = new Map();
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
const payload = { projectId: 7, expectedSnapshotSignature: 'sig-1' };
|
|
|
|
const first = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send(payload);
|
|
const duplicate = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send(payload);
|
|
|
|
expect(first.status).toBe(200);
|
|
expect(duplicate.body).toMatchObject({ success: true, offerId: 88, idempotentReplay: true });
|
|
expect(operationStateStore.set).toHaveBeenCalledWith(
|
|
'7:sig-1', expect.objectContaining({ status: 'completed' }), expect.objectContaining({ ownerId: expect.any(String) })
|
|
);
|
|
expect(graphqlClient.request).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
test('reuses customer_id from the canonical snapshot without creating a duplicate customer', async () => {
|
|
const snapshot = canonicalSnapshot();
|
|
snapshot.artifact.customerProject.customer_id = 77;
|
|
roofQuoteSnapshotService.buildFromProject.mockResolvedValue(snapshot);
|
|
graphqlClient.request
|
|
.mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } })
|
|
.mockImplementationOnce((_query, variables) => ({
|
|
createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line }))
|
|
}));
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.customerId).toBe(77);
|
|
expect(graphqlClient.request).toHaveBeenCalledTimes(2);
|
|
expect(graphqlClient.request.mock.calls[0][0]).toContain('mutation CreateOffer(');
|
|
expect(graphqlClient.request.mock.calls[0][1].input.customerId).toBe(77);
|
|
});
|
|
|
|
test.each([
|
|
[
|
|
'customer',
|
|
() => graphqlClient.request.mockResolvedValueOnce({ createCustomer: null }),
|
|
'CUSTOMER_CREATION_FAILED'
|
|
],
|
|
[
|
|
'offer',
|
|
() => graphqlClient.request
|
|
.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: null }),
|
|
'OFFER_CREATION_FAILED'
|
|
]
|
|
])('returns failure when %s creation does not return a created record', async (_kind, arrange, code) => {
|
|
arrange();
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(response.body).toMatchObject({ success: false, code });
|
|
expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('returns failure when line creation fails and never marks the project sent', async () => {
|
|
graphqlClient.request
|
|
.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } })
|
|
.mockRejectedValueOnce(new Error('line write failed'));
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(response.body.success).toBe(false);
|
|
expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('returns failure when Ordrestyring reports only a partial line creation', async () => {
|
|
graphqlClient.request
|
|
.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } })
|
|
.mockResolvedValueOnce({ createOfferLines: [{ id: 1 }] });
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(response.body.code).toBe('PARTIAL_LINE_CREATION');
|
|
expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('fails exact reconciliation when Ordrestyring returns a changed line', async () => {
|
|
const persisted = new Map();
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
graphqlClient.request
|
|
.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } })
|
|
.mockImplementationOnce((_query, variables) => ({
|
|
createOfferLines: variables.inputs.map((line, index) => ({
|
|
id: index + 1,
|
|
...line,
|
|
...(index === 0 ? { salesPrice: line.salesPrice + 0.01 } : {})
|
|
}))
|
|
}));
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(response.body).toMatchObject({ success: false, code: 'LINE_RECONCILIATION_FAILED' });
|
|
expect(persisted.get('7:sig-1')).toMatchObject({ status: 'failed', offerId: 88, customerId: 42 });
|
|
expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
['unit', line => `${line.unit}-ændret`],
|
|
['discount', line => line.discount + 1],
|
|
['taskId', line => line.taskId + 1],
|
|
['sortOrder', line => line.sortOrder + 1]
|
|
])('fails exact reconciliation when Ordrestyring alters line %s', async (field, alteredValue) => {
|
|
graphqlClient.request
|
|
.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } })
|
|
.mockImplementationOnce((_query, variables) => ({
|
|
createOfferLines: variables.inputs.map((line, index) => ({
|
|
id: index + 1,
|
|
...line,
|
|
...(index === 0 ? { [field]: alteredValue(line) } : {})
|
|
}))
|
|
}));
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(response.body.code).toBe('LINE_RECONCILIATION_FAILED');
|
|
});
|
|
|
|
test('performs and verifies compensating offer deletion after partial line creation', async () => {
|
|
const persisted = new Map();
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
graphqlClient.request
|
|
.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } })
|
|
.mockImplementationOnce((_query, variables) => ({
|
|
createOfferLines: [{ id: 501, ...variables.inputs[0] }]
|
|
}));
|
|
graphqlClient.deleteOffer = jest.fn().mockResolvedValue({ id: 88, deleted: true });
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(response.body).toMatchObject({ success: false, code: 'PARTIAL_LINE_CREATION' });
|
|
expect(graphqlClient.deleteOffer).toHaveBeenCalledWith(88);
|
|
expect(persisted.get('7:sig-1')).toMatchObject({
|
|
status: 'compensated',
|
|
customerId: 42,
|
|
compensatedOfferId: 88
|
|
});
|
|
expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('resumes a known partial operation on the same offer and creates only missing lines', async () => {
|
|
const persisted = new Map();
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
let lineAttempt = 0;
|
|
graphqlClient.request.mockImplementation((query, variables) => {
|
|
if (query.includes('mutation CreateCustomer')) return { createCustomer: { id: 42 } };
|
|
if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88, number: 'T-1001' } };
|
|
lineAttempt += 1;
|
|
if (lineAttempt === 1) {
|
|
return { createOfferLines: variables.inputs.slice(0, 2).map((line, index) => ({ id: 500 + index, ...line })) };
|
|
}
|
|
return { createOfferLines: variables.inputs.map((line, index) => ({ id: 600 + index, ...line })) };
|
|
});
|
|
const app = buildApp(roofQuoteSnapshotService, { operationStateStore });
|
|
const submit = () => request(app)
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
const failed = await submit();
|
|
const resumed = await submit();
|
|
|
|
expect(failed.status).toBe(502);
|
|
expect(resumed.status).toBe(200);
|
|
expect(resumed.body).toMatchObject({ success: true, offerId: 88, lineCount: 6 });
|
|
expect(graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateCustomer'))).toHaveLength(1);
|
|
expect(graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateOffer('))).toHaveLength(1);
|
|
const lineCalls = graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateOfferLines'));
|
|
expect(lineCalls.map(([, variables]) => variables.inputs.length)).toEqual([6, 4]);
|
|
expect(persisted.get('7:sig-1')).toMatchObject({ status: 'completed' });
|
|
});
|
|
|
|
test('reconciles uncertain remote lines before resuming a failed write on the same offer', async () => {
|
|
const persisted = new Map();
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
let lineAttempt = 0;
|
|
let firstDesiredLines;
|
|
graphqlClient.request.mockImplementation((query, variables) => {
|
|
if (query.includes('mutation CreateCustomer')) return { createCustomer: { id: 42 } };
|
|
if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88, number: 'T-1001' } };
|
|
lineAttempt += 1;
|
|
if (lineAttempt === 1) {
|
|
firstDesiredLines = variables.inputs;
|
|
throw new Error('connection lost after remote write');
|
|
}
|
|
return { createOfferLines: variables.inputs.map((line, index) => ({ id: 700 + index, ...line })) };
|
|
});
|
|
graphqlClient.getOfferLines = jest.fn(() => (
|
|
firstDesiredLines.slice(0, 2).map((line, index) => ({ id: 650 + index, ...line }))
|
|
));
|
|
const app = buildApp(roofQuoteSnapshotService, { operationStateStore });
|
|
const submit = () => request(app)
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
const failed = await submit();
|
|
const resumed = await submit();
|
|
|
|
expect(failed.status).toBe(502);
|
|
expect(persisted.get('7:sig-1')).toMatchObject({ status: 'completed', offerId: 88, customerId: 42 });
|
|
expect(resumed.status).toBe(200);
|
|
expect(graphqlClient.getOfferLines).toHaveBeenCalledWith(88);
|
|
const offerMutations = graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateOffer('));
|
|
const customerMutations = graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateCustomer'));
|
|
expect(offerMutations).toHaveLength(1);
|
|
expect(customerMutations).toHaveLength(1);
|
|
});
|
|
|
|
test('keeps remote ids when compensating deletion cannot be verified', async () => {
|
|
const persisted = new Map();
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
graphqlClient.request
|
|
.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } })
|
|
.mockResolvedValueOnce({ createOfferLines: [] });
|
|
graphqlClient.deleteOffer = jest.fn().mockResolvedValue({ id: 999, deleted: true });
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(response.body.success).toBe(false);
|
|
expect(persisted.get('7:sig-1')).toMatchObject({
|
|
status: 'failed', customerId: 42, offerId: 88,
|
|
offer: { id: 88, number: 'T-1001' }
|
|
});
|
|
expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('atomically coalesces concurrent requests across independent router instances', async () => {
|
|
const persisted = new Map();
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
graphqlClient.request.mockImplementation(async (query, variables) => {
|
|
if (query.includes('mutation CreateCustomer')) {
|
|
await new Promise(resolve => setTimeout(resolve, 30));
|
|
return { createCustomer: { id: 42 } };
|
|
}
|
|
if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88, number: 'T-1001' } };
|
|
return { createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line })) };
|
|
});
|
|
const options = { operationStateStore, operationWaitTimeoutMs: 1000 };
|
|
const apps = [buildApp(roofQuoteSnapshotService, options), buildApp(roofQuoteSnapshotService, options)];
|
|
const submit = app => request(app)
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
const responses = await Promise.all(apps.map(submit));
|
|
|
|
expect(responses.map(response => response.status)).toEqual([200, 200]);
|
|
expect(responses.map(response => response.body.offerId)).toEqual([88, 88]);
|
|
expect(responses.filter(response => response.body.idempotentReplay)).toHaveLength(1);
|
|
expect(graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateCustomer'))).toHaveLength(1);
|
|
expect(graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateOffer('))).toHaveLength(1);
|
|
});
|
|
|
|
test('fails closed before remote mutation when the operation store is unavailable', async () => {
|
|
const unavailable = Object.assign(new Error('database unavailable'), { code: 'ECONNREFUSED' });
|
|
const operationStateStore = {
|
|
claim: jest.fn().mockRejectedValue(unavailable),
|
|
get: jest.fn(),
|
|
set: jest.fn(),
|
|
renew: jest.fn()
|
|
};
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STORE_UNAVAILABLE');
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each(['customer_creating', 'offer_creating'])(
|
|
'does not repeat a remote mutation after recovering the %s crash window',
|
|
async status => {
|
|
const persisted = new Map([['7:sig-1', {
|
|
idempotencyKey: '7:sig-1', projectId: 7, snapshotSignature: 'sig-1', status,
|
|
...(status === 'offer_creating' ? { customerId: 42 } : {}),
|
|
leaseOwner: null
|
|
}]]);
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STATE_UNCERTAIN');
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
expect(persisted.get('7:sig-1')).toMatchObject({ status: 'failed' });
|
|
}
|
|
);
|
|
|
|
test('does not mutate a remote offer after a crash during compensation', async () => {
|
|
const persisted = new Map([['7:sig-1', {
|
|
idempotencyKey: '7:sig-1', projectId: 7, snapshotSignature: 'sig-1',
|
|
status: 'compensation_pending', customerId: 42, offerId: 88,
|
|
offer: { id: 88, number: 'T-1001' }, leaseOwner: null
|
|
}]]);
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STATE_UNCERTAIN');
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
expect(graphqlClient.deleteOffer).toBeUndefined();
|
|
expect(persisted.get('7:sig-1')).toMatchObject({ status: 'failed', offerId: 88 });
|
|
});
|
|
|
|
test('persists pending states before every remote mutation', async () => {
|
|
const events = [];
|
|
const operationStateStore = createAtomicOperationStore();
|
|
const originalSet = operationStateStore.set;
|
|
operationStateStore.set = jest.fn(async (...args) => {
|
|
events.push(`state:${args[1].status}`);
|
|
return originalSet(...args);
|
|
});
|
|
graphqlClient.request.mockImplementation((query, variables) => {
|
|
if (query.includes('mutation CreateCustomer')) {
|
|
events.push('remote:customer');
|
|
return { createCustomer: { id: 42 } };
|
|
}
|
|
if (query.includes('mutation CreateOffer(')) {
|
|
events.push('remote:offer');
|
|
return { createOffer: { id: 88, number: 'T-1001' } };
|
|
}
|
|
events.push('remote:lines');
|
|
return { createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line })) };
|
|
});
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(events.indexOf('state:customer_creating')).toBeLessThan(events.indexOf('remote:customer'));
|
|
expect(events.indexOf('state:offer_creating')).toBeLessThan(events.indexOf('remote:offer'));
|
|
expect(events.indexOf('state:lines_creating')).toBeLessThan(events.indexOf('remote:lines'));
|
|
});
|
|
|
|
test('compensates a remotely created offer when persisting its id fails', async () => {
|
|
const persisted = new Map();
|
|
const operationStateStore = createAtomicOperationStore(persisted);
|
|
const originalSet = operationStateStore.set;
|
|
let failedOnce = false;
|
|
operationStateStore.set = jest.fn(async (key, state, options) => {
|
|
if (state.status === 'offer_ready' && !failedOnce) {
|
|
failedOnce = true;
|
|
throw Object.assign(new Error('write failed'), { code: 'ECONNRESET' });
|
|
}
|
|
return originalSet(key, state, options);
|
|
});
|
|
graphqlClient.request
|
|
.mockResolvedValueOnce({ createCustomer: { id: 42 } })
|
|
.mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } });
|
|
graphqlClient.deleteOffer = jest.fn().mockResolvedValue({ id: 88, deleted: true });
|
|
|
|
const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore }))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(graphqlClient.deleteOffer).toHaveBeenCalledWith(88);
|
|
expect(persisted.get('7:sig-1')).toMatchObject({ status: 'compensated', compensatedOfferId: 88 });
|
|
});
|
|
});
|
|
|
|
describe('Offers route legacy boundary', () => {
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
test.each([
|
|
['implicit', { project: { id: 1 }, package: { materials: [] } }],
|
|
['caller-classified', {
|
|
submissionType: 'legacy_non_roof',
|
|
project: { id: 1, name: 'Carport', projectType: 'carport', customer: 'Kunde' },
|
|
package: { materials: [], laborTasks: [], totals: { total: 0 } }
|
|
}]
|
|
])('rejects an %s arbitrary legacy payload', async (_label, payload) => {
|
|
const snapshotService = { buildFromProject: jest.fn() };
|
|
const response = await request(buildApp(snapshotService))
|
|
.post('/api/ordrestyring/offers/create')
|
|
.set('Authorization', authHeader())
|
|
.send(payload);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.code).toBe('INVALID_CANONICAL_PAYLOAD');
|
|
expect(snapshotService.buildFromProject).not.toHaveBeenCalled();
|
|
expect(graphqlClient.request).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('production offer recovery transport', () => {
|
|
const productionClient = transport => require('../src/graphql/mutations/createOffer').createProductionOfferClient(transport);
|
|
test('reads every task and page, converts Money cents, and retains units from remote descriptions', async () => {
|
|
const raw = { id: 1, offer: { id: 88 }, description: 'Arbejde [timer]', quantity: 2, salesPrice: 50000,
|
|
discount: 0, productNumber: 'LABOR', sortOrder: 1 };
|
|
const transport = { request: jest.fn().mockResolvedValueOnce({ offer: { id: 88, tasks: [{ id: 91, number: 1 }] } })
|
|
.mockResolvedValueOnce({ offerLines: { items: [raw], hasMorePages: true, nextCursor: 'next', total: 2 } })
|
|
.mockResolvedValueOnce({ offerLines: { items: [{ ...raw, id: 2 }], hasMorePages: false, total: 2 } }) };
|
|
const result = await productionClient(transport).getOfferLines(88);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toMatchObject({ taskId: 1, unit: 'timer', description: 'Arbejde', salesPrice: 500 });
|
|
expect(transport.request.mock.calls[2][1]).toMatchObject({ taskId: 91, cursor: 'next' });
|
|
});
|
|
test.each([null, { id: 88 }, undefined])('verifies deletion by a fresh offer read: %j', async remaining => {
|
|
const transport = { request: jest.fn().mockResolvedValueOnce({ deleteOffer: null })
|
|
.mockResolvedValueOnce(remaining === undefined ? {} : { offer: remaining }) };
|
|
const result = await productionClient(transport).deleteOffer(88);
|
|
expect(result).toEqual({ id: 88, deleted: remaining === null });
|
|
expect(transport.request.mock.calls[0][1]).toEqual({ ids: [88] });
|
|
expect(transport.request.mock.calls[1][1]).toEqual({ id: 88 });
|
|
});
|
|
test('converts native Money aggregates from cents', async () => {
|
|
const transport = { request: jest.fn().mockResolvedValue({ offer: { id: 88,
|
|
totals: { salesPrice: 179400, salesPriceWithVat: 224250, vat: 44850 } } }) };
|
|
expect(await productionClient(transport).getOfferTotals(88)).toEqual({ salesPrice: 1794, salesPriceWithVat: 2242.5, vat: 448.5 });
|
|
});
|
|
test('serializes native Money and unit descriptions without unsupported input fields', async () => {
|
|
const transport = { request: jest.fn().mockResolvedValueOnce({ createOfferLines: [] })
|
|
.mockResolvedValueOnce({ offer: { id: 88, tasks: [] } }) };
|
|
const { CREATE_OFFER_LINES_MUTATION } = require('../src/graphql/mutations/createOffer');
|
|
await productionClient(transport).request(CREATE_OFFER_LINES_MUTATION, { inputs: [
|
|
{ offerId: 88, taskId: 1, description: 'Arbejde', unit: 'timer', salesPrice: 500, quantity: 2, discount: 0, sortOrder: 1 }
|
|
] });
|
|
expect(transport.request.mock.calls[0][1].inputs[0]).toEqual({ offerId: 88, taskId: 1,
|
|
description: 'Arbejde [timer]', salesPrice: 50000, quantity: 2, discount: 0, sortOrder: 1 });
|
|
});
|
|
});
|
|
|
|
describe('production transport through authenticated route', () => {
|
|
test.each([false, true])('verifies native totals and the complete remote line set, extra line=%s', async extra => {
|
|
let remoteLines = [];
|
|
let deleted = false;
|
|
const totals = { salesPrice: 179400, salesPriceWithVat: 224250, vat: 44850 };
|
|
const transport = { request: jest.fn(async (query, variables) => {
|
|
if (query.includes('CreateCustomer')) return { createCustomer: { id: 42 } };
|
|
if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88 } };
|
|
if (query.includes('CreateOfferLines')) {
|
|
remoteLines = variables.inputs.map((line, i) => ({ ...line, id: i + 1, sortOrder: i + 1, offer: { id: 88 } }));
|
|
if (extra) remoteLines.push({ ...remoteLines[0], id: 99, sortOrder: 99 });
|
|
return { createOfferLines: remoteLines };
|
|
}
|
|
if (query.includes('OfferLinesRecovery')) return { offerLines: { items: remoteLines, hasMorePages: false, total: remoteLines.length } };
|
|
if (query.includes('OfferRecovery')) return { offer: { id: 88, tasks: [{ id: 91, number: 1 }], totals } };
|
|
if (query.includes('DeleteOffer')) { deleted = true; return { deleteOffer: null }; }
|
|
if (query.includes('OfferExists')) return { offer: deleted ? null : { id: 88 } };
|
|
throw new Error('Unexpected query');
|
|
}) };
|
|
const { createProductionOfferClient } = require('../src/graphql/mutations/createOffer');
|
|
const snapshotService = { buildFromProject: jest.fn().mockResolvedValue(canonicalSnapshot()), assertExpectedSignature: jest.fn() };
|
|
const response = await request(buildApp(snapshotService, {
|
|
graphqlClient: createProductionOfferClient(transport),
|
|
quoteRealismService: { requireApprovedAnalysis: jest.fn() }
|
|
})).post('/api/ordrestyring/offers/create').set('Authorization', authHeader())
|
|
.send({ projectId: 7, expectedSnapshotSignature: 'sig-1' });
|
|
expect(response.status).toBe(extra ? 502 : 200);
|
|
if (extra) expect(response.body.code).toBe('LINE_RECONCILIATION_FAILED');
|
|
expect(deleted).toBe(extra);
|
|
});
|
|
});
|