fix(quotes): enforce the status state machine, and correct the table

VALID_QUOTE_TRANSITIONS was a complete-looking quote state machine that
nothing consulted, so status changes were unvalidated.

Mapping every writer of quotes.status (quoteService.js is the only one --
dealsService, projectService, adminDashboard and customer.js all read) showed
the table itself was wrong: six legitimate transitions were missing.
sendQuote allows draft/declined/expired -> sent but the table had draft only;
adminAcceptQuote allows draft/sent/expired but had sent only;
adminDeclineQuote allows draft/sent/expired but had draft/sent; recordResponse
had no same-status entry. Enforcing it as written would have broken
accept-on-behalf from a draft, resend-after-decline, every expired revival and
the 15-minute response-toggle window.

So the table is reconciled to reality first, then assertQuoteTransition()
(409, QUOTE_INVALID_TRANSITION) is called at all seven sites.

Two things worth carrying forward. Nothing in the codebase ever sets
'expired' -- the header comment says "set by the scheduler" and there is no
such scheduler; sent -> expired is retained as documented intent only. And
the backstop's added value is narrow: every reachable invalid transition is
already caught by a call site's own better-worded guard, which fires first.
What it newly catches is a status the machine has never heard of -- a legacy
or corrupt row like 'cancelled' sails through adminAcceptQuote's guard, which
only excludes accepted/declined/converted, and used to be silently
overwritten. That is what the new tests pin.

Refs testplan REPORT.md B4.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent a7d45ddd0d
commit 103863cbab
2 changed files with 102 additions and 9 deletions
@@ -177,3 +177,59 @@ describe('quoteService.adminAcceptQuote', () => {
}
});
});
/**
* VALID_QUOTE_TRANSITIONS was a complete state machine that nothing consulted,
* so `quotes.status` writes were unvalidated. It is now asserted at every
* status-change site — and reconciled against them, since the original table
* was missing several transitions the service legitimately performs
* (draft→accepted, expired→accepted/declined/sent, declined→sent, and the
* same-status re-affirm inside the response window). The
* "accepts draft / sent / expired" case above is the valid-transition
* coverage for the admin-accept path; these pin the rest.
*/
describe('quoteService — quote status transition backstop', () => {
beforeEach(() => resetChains());
const respond = (quote, action = 'accept') => {
pickChainFor('quote_action_tokens')._firstValue = { id: 7, quote_id: quote.id, expires_at: null };
pickChainFor('quotes')._firstValue = quote;
return quoteService.recordResponse({ token: 'tok', action, ip: '127.0.0.1' });
};
it('allows sent → accepted', async () => {
const result = await respond({ id: 1, status: 'sent' });
expect(result.status).toBe('accepted');
});
it('allows the accepted → accepted re-affirm inside the toggle window', async () => {
const now = Date.now();
const result = await respond({
id: 1,
status: 'accepted',
responded_at: new Date(now - 60_000).toISOString(),
response_locked_at: new Date(now + 10 * 60_000).toISOString(),
});
expect(result.status).toBe('accepted');
});
it('refuses to respond to a converted quote', async () => {
await expect(respond({ id: 1, status: 'converted' }))
.rejects.toMatchObject({ statusCode: 409 });
});
it('409s (not a silent overwrite) on a status the machine does not know', async () => {
// A legacy / corrupt row. adminAcceptQuote's own guard only excludes
// accepted / declined / converted, so 'cancelled' sailed straight through
// and got overwritten with 'accepted'.
pickChainFor('quotes')._firstValue = { id: 1, status: 'cancelled', customer_account_id: 5 };
await expect(quoteService.adminAcceptQuote(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_INVALID_TRANSITION' });
});
it('409s the same way on the admin decline path', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'cancelled' };
await expect(quoteService.adminDeclineQuote(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_INVALID_TRANSITION' });
});
});