feat: add car inquiries with resilience, ownership privacy, and retry visibility

Introduce plate, chassis, and third-party car endpoints, a wall-clock inquiry
deadline with transport-only retries, and ownership-safe no-match errors so
another person's identity never leaks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 17:21:16 +03:30
parent b4be98b156
commit 61098f1bf4
40 changed files with 2595 additions and 374 deletions

View File

@@ -0,0 +1,67 @@
import {
assertCarPolicyOwnedBy,
createCarOwnershipMismatchError,
isCarOwnershipMatch,
publicErrorLeaksNationalCode,
} from '../../src/common/helpers/car-inquiry-safety.helper';
const REQUESTED = '6269944419';
const OWNER = '4311402422';
function formatError(
providerMessage?: string,
providerCode?: string,
fallbackMessage?: string,
extras?: Record<string, unknown>,
): Error {
const error = new Error(fallbackMessage ?? providerMessage ?? 'failed');
(
error as Error & {
normalizedError: {
code?: string;
message: string;
providerMessage?: string;
conflict?: Record<string, string>;
};
}
).normalizedError = {
code: providerCode,
message: fallbackMessage ?? 'failed',
providerMessage,
conflict: extras?.conflict as Record<string, string> | undefined,
};
return error;
}
describe('car inquiry privacy', () => {
it('treats a plate/chassis record as a match only when owner national code equals the requester', () => {
expect(isCarOwnershipMatch(REQUESTED, { NtnlId: OWNER })).toBe(false);
expect(isCarOwnershipMatch(OWNER, { NtnlId: OWNER })).toBe(true);
expect(isCarOwnershipMatch(REQUESTED, {})).toBe(false);
});
it('never puts the real owner national code on a mismatch error', () => {
const error = createCarOwnershipMismatchError(formatError);
const payload = (error as Error & { normalizedError: unknown }).normalizedError;
expect(publicErrorLeaksNationalCode(payload, OWNER)).toBe(false);
expect(payload).toMatchObject({
code: 'INQUIRY_NO_MATCH',
message: 'Inquiry returned no matching result',
});
expect((payload as { conflict?: unknown }).conflict).toBeUndefined();
});
it('throws the same generic no-match when plate belongs to someone else', () => {
expect(() =>
assertCarPolicyOwnedBy(REQUESTED, { NtnlId: OWNER, InsNam: 'سهيل حاجي زاده' }, formatError),
).toThrow();
try {
assertCarPolicyOwnedBy(REQUESTED, { NtnlId: OWNER }, formatError);
} catch (error) {
expect(publicErrorLeaksNationalCode(error, OWNER)).toBe(false);
expect(JSON.stringify(error)).not.toContain('سهيل');
}
});
});

View File

@@ -0,0 +1,113 @@
import {
AUTH_MAX_ATTEMPTS,
buildAttemptSummary,
createAttemptTrail,
isAuthTransportRetryable,
isTransportRetryable,
recordResilienceAttempt,
shouldExposeAttemptSummary,
} from '../../src/common/helpers/provider-resilience.helper';
import { withRetry } from '../../src/common/helpers/retry.helper';
import { ProviderExecutionContext } from '../../src/common/interfaces/inquiry-provider.interface';
describe('provider resilience helpers', () => {
it('retries transport failures but not 429 or business rejects', () => {
expect(isTransportRetryable({ code: 'ETIMEDOUT' })).toBe(true);
expect(isTransportRetryable({ response: { status: 503 } })).toBe(true);
expect(isTransportRetryable({ response: { status: 429 } })).toBe(false);
expect(
isTransportRetryable({
normalizedError: { code: 'INQUIRY_NO_MATCH', message: 'no match' },
}),
).toBe(false);
});
it('auth retry is timeout/network only', () => {
expect(isAuthTransportRetryable({ code: 'ECONNRESET' })).toBe(true);
expect(isAuthTransportRetryable({ response: { status: 401 } })).toBe(false);
expect(isAuthTransportRetryable({ response: { status: 500 } })).toBe(false);
expect(AUTH_MAX_ATTEMPTS).toBe(2);
});
it('records every HTTP attempt and exposes summary on failure or retried success', () => {
const context: ProviderExecutionContext = {
requestId: 'req-1',
trackingCode: 'trk-1',
attemptTrail: createAttemptTrail(),
};
recordResilienceAttempt(context, 'HAMTA', {
attempt: 1,
durationMs: 100,
succeeded: false,
error: {
normalizedError: {
code: 'PROVIDER_TIMEOUT',
message: 'Provider request timed out',
messageFa: 'درخواست به سرویس‌دهنده زمان‌بر شد',
},
},
});
recordResilienceAttempt(context, 'HAMTA', {
attempt: 2,
durationMs: 80,
succeeded: true,
});
const summary = buildAttemptSummary(context.attemptTrail, 250);
expect(summary).toMatchObject({
totalAttempts: 2,
retried: true,
durationMs: 250,
});
expect(summary.attempts).toHaveLength(1);
expect(summary.attempts[0]).toMatchObject({
attempt: 1,
provider: 'HAMTA',
code: 'PROVIDER_TIMEOUT',
});
expect(shouldExposeAttemptSummary(true, summary)).toBe(true);
expect(
shouldExposeAttemptSummary(true, {
totalAttempts: 1,
retried: false,
durationMs: 10,
attempts: [],
}),
).toBe(false);
expect(
shouldExposeAttemptSummary(false, {
totalAttempts: 1,
retried: false,
durationMs: 10,
attempts: [],
}),
).toBe(true);
});
it('stops retrying when the deadline budget is exhausted', async () => {
let calls = 0;
const deadlineAt = Date.now() + 50;
await expect(
withRetry(
async () => {
calls += 1;
const error = new Error('timeout');
(error as NodeJS.ErrnoException).code = 'ETIMEDOUT';
throw error;
},
{
maxAttempts: 3,
delayMs: 40,
deadlineAt,
minRemainingMs: 100,
shouldRetry: isTransportRetryable,
},
),
).rejects.toMatchObject({ code: 'ETIMEDOUT' });
expect(calls).toBe(1);
});
});

View File

@@ -0,0 +1,49 @@
import {
INQUIRY_DEADLINE_MS,
createAttemptTrail,
runWithProviderResilience,
toAttemptErrorFields,
} from '../../src/common/helpers/provider-resilience.helper';
import { ProviderExecutionContext } from '../../src/common/interfaces/inquiry-provider.interface';
describe('timeout / deadline resilience bugs', () => {
it('maps withTimeout ETIMEDOUT errors to PROVIDER_TIMEOUT (not PROVIDER_ERROR)', () => {
const error = new Error('PARSIAN request timed out after 20000ms');
(error as NodeJS.ErrnoException).code = 'ETIMEDOUT';
expect(toAttemptErrorFields(error)).toMatchObject({
code: 'PROVIDER_TIMEOUT',
messageFa: expect.stringMatching(/زمان/),
});
});
it('caps attempt timeout by inquiry deadline even when provider timeout is higher', async () => {
const context: ProviderExecutionContext = {
requestId: 'req',
trackingCode: 'trk',
deadlineAt: Date.now() + 80,
attemptTrail: createAttemptTrail(),
};
const started = Date.now();
await expect(
runWithProviderResilience(
() => new Promise((resolve) => setTimeout(() => resolve('late'), 500)),
{
providerName: 'PARSIAN',
maxAttempts: 1,
timeoutMs: 30_000,
context,
label: 'PARSIAN request',
},
),
).rejects.toMatchObject({ code: 'ETIMEDOUT' });
expect(Date.now() - started).toBeLessThan(250);
});
it('default inquiry deadline must exceed a single slow CentInsur SOAP (~30s)', () => {
// thirdPartyCar may need two SOAP calls over a proxy; 20s was killing successful ~28s responses
expect(INQUIRY_DEADLINE_MS).toBeGreaterThanOrEqual(60_000);
});
});