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

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