fix(web): bound stalled mutations
This commit is contained in:
@@ -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<string, unknown> = {}) {
|
||||
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<Response>((_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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user