forked from Shared/esg
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:
67
test/unit/car-inquiry-safety.spec.ts
Normal file
67
test/unit/car-inquiry-safety.spec.ts
Normal 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('سهيل');
|
||||
}
|
||||
});
|
||||
});
|
||||
113
test/unit/provider-resilience.spec.ts
Normal file
113
test/unit/provider-resilience.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
test/unit/timeout-deadline-bugs.spec.ts
Normal file
49
test/unit/timeout-deadline-bugs.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user