diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 1c12b8fc..4323eb14 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -2,6 +2,10 @@ import { afterEach, expect, test, vi } from 'vitest'; import { API_QUERY_TIMEOUT_MS, api } from './client'; import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from '../v2/auth/session'; +function boundedRequest(init: Record = {}) { + return { ...init, signal: expect.any(AbortSignal) }; +} + afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); @@ -77,11 +81,11 @@ test('a delayed 401 from an old token cannot terminate a newer login', async () test('durable alert APIs keep versioned actions, rules and notification reads explicit', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-alert', timestamp: 1 }) } as Response); await api.alertEventsV2({ status: 'unprocessed', limit: 20, offset: 0 }); - expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'unprocessed', limit: 20, offset: 0 }) }); + expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'unprocessed', limit: 20, offset: 0 }) })); await api.actOnAlertV2('alert 1', { version: 2, action: 'acknowledge', note: '已确认' }); - expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events/alert%201/actions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 2, action: 'acknowledge', note: '已确认' }) }); + expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events/alert%201/actions', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 2, action: 'acknowledge', note: '已确认' }) })); await api.readAlertNotificationsV2([7, 8]); - expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/notifications/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [7, 8] }) }); + expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/notifications/read', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [7, 8] }) })); }); test('access APIs post one shared filter contract and version threshold updates', async () => { @@ -91,35 +95,35 @@ test('access APIs post one shared filter contract and version threshold updates' } as Response); const query = { protocol: 'JT808', onlineState: 'offline', limit: 50, offset: 0 }; await api.accessVehicles(query); - expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/vehicles', { + expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/vehicles', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) - }); + })); const unresolved = { protocol: 'JT808', limit: 20, offset: 0 }; await api.accessUnresolvedIdentities(unresolved); - expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/unresolved-identities', { + expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/unresolved-identities', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(unresolved) - }); + })); await api.updateAccessThresholds({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] }); - expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/thresholds', { + expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/thresholds', boundedRequest({ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] }) - }); + })); }); test('vehicle profile API uses encoded VIN and optimistic version updates', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-profile', timestamp: 1 }) } as Response); const input = { modelName: '氢燃料重卡', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '2026-07-01T08:30', runtimeSeconds: 3600, version: 2 }; await api.updateVehicleProfile('VIN 001', input); - expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/VIN%20001/profile', { + expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/VIN%20001/profile', boundedRequest({ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) - }); + })); }); test('latest telemetry uses the encoded vehicle identity path', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { categories: [], values: [] }, traceId: 'trace-telemetry', timestamp: 1 }) } as Response); await api.latestTelemetry('粤A 001'); - expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/%E7%B2%A4A%20001/telemetry/latest', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/%E7%B2%A4A%20001/telemetry/latest', boundedRequest()); }); test('vehicle profile sync posts an explicit dry-run and conflict policy contract', async () => { @@ -129,9 +133,9 @@ test('vehicle profile sync posts an explicit dry-run and conflict policy contrac items: [{ vin: 'VIN001', modelName: '车型一', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '', runtimeSeconds: null }] }; await api.syncVehicleProfiles(input); - expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicle-profiles/sync', { + expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicle-profiles/sync', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) - }); + })); }); test('trackPlayback preserves the bounded V2 query contract', async () => { @@ -140,7 +144,7 @@ test('trackPlayback preserves the bounded V2 query contract', async () => { json: async () => ({ data: { vin: 'VIN001', points: [], events: [], sources: [], summary: { pointCount: 0 } }, traceId: 'trace-test', timestamp: 1783094400000 }) } as Response); await api.trackPlayback(new URLSearchParams({ keyword: '粤AG18312', maxPoints: '1200' })); - expect(fetchMock).toHaveBeenCalledWith('/api/v2/tracks?keyword=%E7%B2%A4AG18312&maxPoints=1200', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/v2/tracks?keyword=%E7%B2%A4AG18312&maxPoints=1200', boundedRequest()); }); test('monitor viewport requests forward AbortSignal so obsolete pans can be cancelled', async () => { @@ -221,6 +225,27 @@ test('turns a stalled route query into a bounded retryable timeout', async () => expect(controller.signal.aborted).toBe(false); }); +test('bounds a stalled mutation and aborts its transport without an upstream signal', async () => { + vi.useFakeTimers(); + let transportSignal: AbortSignal | undefined; + vi.spyOn(globalThis, 'fetch').mockImplementation((_path, init) => { + transportSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + transportSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); + }); + }); + + const timedOut = expect(api.actOnAlertV2('alert-1', { version: 1, action: 'acknowledge', note: '' })).rejects.toMatchObject({ + name: 'ApiRequestTimeoutError', + message: expect.stringContaining('15 秒') + }); + await vi.advanceTimersByTimeAsync(API_QUERY_TIMEOUT_MS); + + await timedOut; + expect(transportSignal).toBeInstanceOf(AbortSignal); + expect(transportSignal?.aborted).toBe(true); +}); + test('preserves navigation cancellation instead of reporting it as a timeout', async () => { const controller = new AbortController(); let transportSignal: AbortSignal | undefined; @@ -257,7 +282,7 @@ test('rawFramesQuery posts structured JSON instead of URL query strings', async offset: 0 }); - expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', { + expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -268,7 +293,7 @@ test('rawFramesQuery posts structured JSON instead of URL query strings', async limit: 20, offset: 0 }) - }); + })); }); test('vehicleResolve sends keyword to the identity resolution endpoint', async () => { @@ -293,7 +318,7 @@ test('vehicleResolve sends keyword to the identity resolution endpoint', async ( const result = await api.vehicleResolve(new URLSearchParams({ keyword: '粤AG18312' })); - expect(fetchMock).toHaveBeenCalledWith('/api/vehicles/resolve?keyword=%E7%B2%A4AG18312', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/vehicles/resolve?keyword=%E7%B2%A4AG18312', boundedRequest()); expect(result.vin).toBe('LB9A32A24R0LS1426'); }); @@ -333,7 +358,7 @@ test('vehicleServiceOverview sends keyword to the lightweight overview endpoint' const result = await api.vehicleServiceOverview(new URLSearchParams({ keyword: '粤AG18312' })); - expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overview?keyword=%E7%B2%A4AG18312', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overview?keyword=%E7%B2%A4AG18312', boundedRequest()); expect(result.coverageStatus).toBe('partial'); expect(result.serviceStatus?.status).toBe('degraded'); }); @@ -362,7 +387,7 @@ test('vehicleServiceOverviews posts keywords to the batch overview endpoint', as offset: 0 }); - expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overviews', { + expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overviews', boundedRequest({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -370,7 +395,7 @@ test('vehicleServiceOverviews posts keywords to the batch overview endpoint', as limit: 200, offset: 0 }) - }); + })); expect(result.total).toBe(2); }); @@ -399,7 +424,7 @@ test('vehicleServiceSummary reads the vehicle service summary endpoint', async ( const result = await api.vehicleServiceSummary(); - expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', boundedRequest()); expect(result.totalVehicles).toBe(1033); expect(result.multiSourceVehicles).toBe(181); expect(result.serviceStatuses.find((item) => item.status === 'no_data')?.count).toBe(461); @@ -426,7 +451,7 @@ test('reverseGeocode reads the server-side AMap reverse geocode endpoint', async const result = await api.reverseGeocode(new URLSearchParams({ longitude: '113.2', latitude: '23.1' })); - expect(fetchMock).toHaveBeenCalledWith('/api/map/reverse-geocode?longitude=113.2&latitude=23.1', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/map/reverse-geocode?longitude=113.2&latitude=23.1', boundedRequest()); expect(result.formattedAddress).toBe('广东省广州市天河区测试路'); }); @@ -449,7 +474,7 @@ test('qualityNotificationPlan reads alert rules and priority issues from backend const result = await api.qualityNotificationPlan(new URLSearchParams({ issueType: 'NO_SOURCE' })); - expect(fetchMock).toHaveBeenCalledWith('/api/quality/notification-plan?issueType=NO_SOURCE', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/quality/notification-plan?issueType=NO_SOURCE', boundedRequest()); expect(result.rules[0].level).toBe('P0'); expect(result.policies[0].escalationMinutes).toBe(30); expect(result.policies[0].acceptanceCriteria).toContain('来源恢复'); @@ -483,7 +508,7 @@ test('alertEvents reads the vehicle alert event endpoint', async () => { const result = await api.alertEvents(new URLSearchParams({ protocol: 'JT808', limit: '20' })); - expect(fetchMock).toHaveBeenCalledWith('/api/alert-events?protocol=JT808&limit=20', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/alert-events?protocol=JT808&limit=20', boundedRequest()); expect(result.items[0].issueType).toBe('LINK_GAP'); }); @@ -516,7 +541,7 @@ test('onlineVehicleStatuses reads paged online vehicle status rows', async () => const result = await api.onlineVehicleStatuses(new URLSearchParams({ keyword: '粤AG18312', limit: '10' })); - expect(fetchMock).toHaveBeenCalledWith('/api/statistics/online-vehicles?keyword=%E7%B2%A4AG18312&limit=10', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/statistics/online-vehicles?keyword=%E7%B2%A4AG18312&limit=10', boundedRequest()); expect(result.items[0].offlineDurationMinutes).toBe(18); }); @@ -556,7 +581,7 @@ test('sourceReadiness reads ops source readiness plan', async () => { const result = await api.sourceReadiness(); - expect(fetchMock).toHaveBeenCalledWith('/api/ops/source-readiness', undefined); + expect(fetchMock).toHaveBeenCalledWith('/api/ops/source-readiness', boundedRequest()); expect(result.platformRelease).toBe('platform-source-test'); expect(result.sources[0].protocol).toBe('GB32960'); }); diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 0c173edb..31f2ea10 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -89,12 +89,11 @@ export const API_QUERY_TIMEOUT_MS = 15_000; function queryTimeout(init: RequestInit | undefined) { const upstream = init?.signal; - if (!upstream) return undefined; const controller = new AbortController(); let timedOut = false; - const abortFromUpstream = () => controller.abort(upstream.reason); - if (upstream.aborted) abortFromUpstream(); - else upstream.addEventListener('abort', abortFromUpstream, { once: true }); + const abortFromUpstream = () => controller.abort(upstream?.reason); + if (upstream?.aborted) abortFromUpstream(); + else upstream?.addEventListener('abort', abortFromUpstream, { once: true }); const timer = window.setTimeout(() => { timedOut = true; controller.abort(); @@ -104,7 +103,7 @@ function queryTimeout(init: RequestInit | undefined) { timedOut: () => timedOut, cleanup: () => { window.clearTimeout(timer); - upstream.removeEventListener('abort', abortFromUpstream); + upstream?.removeEventListener('abort', abortFromUpstream); } }; } diff --git a/vehicle-data-platform/apps/web/src/test/Mileage.test.tsx b/vehicle-data-platform/apps/web/src/test/Mileage.test.tsx index cf9afb65..911a7b2d 100644 --- a/vehicle-data-platform/apps/web/src/test/Mileage.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/Mileage.test.tsx @@ -41,6 +41,6 @@ test('renders date-range mileage summary, trend and daily detail list', async () expect((await screen.findAllByText('210.5 km')).length).toBeGreaterThanOrEqual(1); expect(screen.getByText('粤A12345')).toBeInTheDocument(); expect(screen.getAllByText('80.5').length).toBeGreaterThanOrEqual(1); - await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined)); - expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('keyword=%E7%B2%A4A12345'), undefined); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), { signal: expect.any(AbortSignal) })); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('keyword=%E7%B2%A4A12345'), { signal: expect.any(AbortSignal) }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx index 73b24ee2..c8944206 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx @@ -72,6 +72,18 @@ test('loads one full notification query on a direct notifications entry', async expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('limit=100'); }); +test('ends notification mutation feedback with a visible retryable error instead of a silent pending state', async () => { + mocks.alertNotificationsV2.mockResolvedValue({ items: [{ id: 7, eventId: 'event-7', title: '车辆告警', content: '测试告警', severity: 'major', read: false, createdAt: '2026-07-16T04:00:00Z' }], total: 1, limit: 100, offset: 0 }); + mocks.readAlertNotificationsV2.mockRejectedValue(new Error('通知状态更新超时')); + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + render(); + + fireEvent.click(await screen.findByRole('button', { name: '标为已读' })); + + expect(await screen.findByText('通知状态更新超时')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '标为已读' })).toBeEnabled(); +}); + test('clears old alert rows and inspector evidence when the event scope changes', async () => { const oldEvent = alertEvent('old-event', 'OLDVIN', '旧告警车牌'); const newEvent = alertEvent('new-event', 'NEWVIN', '新告警车牌'); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx index 2fd26bfd..5201b7fd 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -79,6 +79,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri useEffect(() => { if (!selectedID && rules[0]) { setSelectedID(rules[0].id); setDraft(ruleDraft(rules[0])); } }, [rules, selectedID]); const save = useMutation({ mutationFn: api.saveAlertRuleV2, onSuccess: async (rule) => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } }); const toggle = useMutation({ mutationFn: (rule: AlertRule) => api.setAlertRuleEnabledV2(rule.id, { version: rule.version, enabled: !rule.enabled }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } }); + const mutationError = save.error ?? toggle.error; const operators = draft.valueType === 'boolean' ? BOOLEAN_OPERATORS : NUMERIC_OPERATORS; const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType); const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label])); @@ -86,7 +87,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri return
规则配置
{rules.map((rule) => )}
{ event.preventDefault(); save.mutate(draft); }}> -
{draft.version ? '编辑规则' : '新建规则'}数值、状态与主数据范围均纳入版本审计
{draft.version ? : null}
+
{draft.version ? '编辑规则' : '新建规则'}数值、状态与主数据范围均纳入版本审计
{draft.version ? : null}
@@ -106,7 +107,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri