Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26cc166282 | ||
|
|
30e878979c | ||
|
|
0f335e0984 |
@@ -51,6 +51,7 @@ type LPRResponse = {
|
||||
};
|
||||
|
||||
const latestLPRResponse = ref<LPRResponse | null>(null);
|
||||
const isLPRRequestInFlight = ref(false);
|
||||
const LPR_IMAGE_MAX_WIDTH = 1280;
|
||||
const LPR_IMAGE_MAX_HEIGHT = 720;
|
||||
const LPR_IMAGE_JPEG_QUALITY = 0.72;
|
||||
@@ -116,6 +117,9 @@ const handleLPRResult = () => {
|
||||
};
|
||||
|
||||
const parseImage = async (image: string) => {
|
||||
if (isLPRRequestInFlight.value) {
|
||||
return;
|
||||
}
|
||||
// Check if the time since the last successful parse is enough
|
||||
if (!camera.hasDelayAfterSuccessPassed()) {
|
||||
return;
|
||||
@@ -126,31 +130,35 @@ const parseImage = async (image: string) => {
|
||||
}
|
||||
lastParsedImage.value = image; // Update the last parsed image
|
||||
camera.setLatestImage(image); // Update the latest image in the camera object
|
||||
// Function to parse the image data
|
||||
const compressedImage = await compressImageForLPR(image);
|
||||
SessionUser.request("/modules/scanner/lpr", "POST", {
|
||||
base64_image: compressedImage,
|
||||
})
|
||||
.then((response) => {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(response);
|
||||
}
|
||||
// If the response is not successful, stop here.
|
||||
if (!response.data.success) {
|
||||
return;
|
||||
}
|
||||
latestLPRResponse.value = response.data.data as LPRResponse;
|
||||
// Set the last successful capture time
|
||||
camera.setLastSuccess();
|
||||
// Handle parsed result.
|
||||
handleLPRResult();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(error);
|
||||
}
|
||||
//console.error("Error parsing image:", error);
|
||||
isLPRRequestInFlight.value = true;
|
||||
|
||||
try {
|
||||
// Function to parse the image data
|
||||
const compressedImage = await compressImageForLPR(image);
|
||||
const response = await SessionUser.request("/modules/scanner/lpr", "POST", {
|
||||
base64_image: compressedImage,
|
||||
});
|
||||
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(response);
|
||||
}
|
||||
// If the response is not successful, stop here.
|
||||
if (!response.data.success) {
|
||||
return;
|
||||
}
|
||||
latestLPRResponse.value = response.data.data as LPRResponse;
|
||||
// Set the last successful capture time
|
||||
camera.setLastSuccess();
|
||||
// Handle parsed result.
|
||||
handleLPRResult();
|
||||
} catch (error) {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(error);
|
||||
}
|
||||
//console.error("Error parsing image:", error);
|
||||
} finally {
|
||||
isLPRRequestInFlight.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(manualInput, (newValue) => {
|
||||
|
||||
@@ -15,6 +15,29 @@ const getSelectedCustomerNumber = () => {
|
||||
|
||||
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
|
||||
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
|
||||
const SELF_SERVE_HARDWARE_QUEUE_GROUP = 'SELF_SERVE_HARDWARE';
|
||||
const POS_SCANNER_QUEUE_GROUP = 'POS_SCANNER';
|
||||
const POS_STRIPE_QUEUE_GROUP = 'POS_STRIPE';
|
||||
const SELF_SERVE_HARDWARE_ENDPOINTS = [
|
||||
'/modules/self-serve/lane/command',
|
||||
'/modules/self-serve/lane/relay/',
|
||||
'/modules/self-serve/lane/gate/open',
|
||||
'/modules/self-serve/lane/force/machine',
|
||||
];
|
||||
const POS_LATENCY_QUEUE_RULES = [
|
||||
{
|
||||
endpoints: ['/modules/scanner/lpr'],
|
||||
queueGroup: POS_SCANNER_QUEUE_GROUP,
|
||||
concurrencyLimit: 2,
|
||||
retryByStatusCode: {},
|
||||
},
|
||||
{
|
||||
endpoints: ['/modules/stripe/invoice'],
|
||||
queueGroup: POS_STRIPE_QUEUE_GROUP,
|
||||
concurrencyLimit: 2,
|
||||
retryByStatusCode: {},
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
|
||||
|
||||
@@ -65,6 +88,52 @@ const normalizeActiveWashResponse = (url, method, response) => {
|
||||
};
|
||||
};
|
||||
|
||||
const isSelfServeHardwareMutation = (url, method) => {
|
||||
const normalizedMethod = String(method || '').trim().toUpperCase();
|
||||
if (normalizedMethod === 'GET') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedUrl = String(url || '');
|
||||
return SELF_SERVE_HARDWARE_ENDPOINTS.some((endpoint) => normalizedUrl.includes(endpoint));
|
||||
};
|
||||
|
||||
const findPosLatencyQueueRule = (url, method) => {
|
||||
const normalizedMethod = String(method || '').trim().toUpperCase();
|
||||
if (normalizedMethod === 'GET') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedUrl = String(url || '');
|
||||
return POS_LATENCY_QUEUE_RULES.find((rule) =>
|
||||
rule.endpoints.some((endpoint) => normalizedUrl.includes(endpoint))
|
||||
) || null;
|
||||
};
|
||||
|
||||
const buildRequestQueueOptions = (url, method, options = {}) => {
|
||||
const queueOptions = {
|
||||
retryByStatusCode: options?.retryByStatusCode,
|
||||
shouldRetry: options?.shouldRetry,
|
||||
queueGroup: options?.queueGroup,
|
||||
concurrencyLimit: options?.concurrencyLimit,
|
||||
};
|
||||
|
||||
if (isSelfServeHardwareMutation(url, method)) {
|
||||
queueOptions.retryByStatusCode ??= {};
|
||||
queueOptions.queueGroup ??= SELF_SERVE_HARDWARE_QUEUE_GROUP;
|
||||
queueOptions.concurrencyLimit ??= 1;
|
||||
}
|
||||
|
||||
const posQueueRule = findPosLatencyQueueRule(url, method);
|
||||
if (posQueueRule) {
|
||||
queueOptions.retryByStatusCode ??= posQueueRule.retryByStatusCode;
|
||||
queueOptions.queueGroup ??= posQueueRule.queueGroup;
|
||||
queueOptions.concurrencyLimit ??= posQueueRule.concurrencyLimit;
|
||||
}
|
||||
|
||||
return queueOptions;
|
||||
};
|
||||
|
||||
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
@@ -105,7 +174,8 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
|
||||
params: method === 'GET' ? data : null,
|
||||
data: method === 'GET' ? null : data,
|
||||
headers,
|
||||
}
|
||||
},
|
||||
...buildRequestQueueOptions(requestUrl, method, options),
|
||||
}
|
||||
)
|
||||
.catch((error) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const cameraStream = ref<MediaStream | null>(null);
|
||||
const isCameraActive = ref(false);
|
||||
const cameraErrorKey = ref('pos.camera_permission_denied');
|
||||
let captureIntervalId: ReturnType<typeof window.setInterval> | null = null;
|
||||
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
|
||||
|
||||
const getCameraErrorKey = (err: unknown) => {
|
||||
@@ -22,6 +23,10 @@ const getCameraErrorKey = (err: unknown) => {
|
||||
};
|
||||
|
||||
function startCamera() {
|
||||
if (cameraStream.value || isCameraActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const constraints = {
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
@@ -49,12 +54,14 @@ function startCamera() {
|
||||
navigator.mediaDevices.getUserMedia(constraints)
|
||||
.then((stream) => {
|
||||
isCameraActive.value = true;
|
||||
isCameraMounted.value = true;
|
||||
cameraStream.value = stream;
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = stream;
|
||||
videoRef.value.setAttribute('playsinline', '');
|
||||
videoRef.value.play();
|
||||
}
|
||||
startCaptureInterval();
|
||||
})
|
||||
.catch((err) => {
|
||||
isCameraActive.value = false;
|
||||
@@ -63,7 +70,24 @@ function startCamera() {
|
||||
});
|
||||
}
|
||||
|
||||
function clearCaptureInterval() {
|
||||
if (captureIntervalId !== null) {
|
||||
window.clearInterval(captureIntervalId);
|
||||
captureIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCaptureInterval() {
|
||||
clearCaptureInterval();
|
||||
captureIntervalId = window.setInterval(() => {
|
||||
if (isCameraActive.value) {
|
||||
getFrame();
|
||||
}
|
||||
}, camera.getImageCaptureDelay(false));
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
clearCaptureInterval();
|
||||
if (cameraStream.value) {
|
||||
cameraStream.value.getTracks().forEach(track => track.stop());
|
||||
cameraStream.value = null;
|
||||
@@ -139,16 +163,8 @@ watch(() => camera.getZoom(), (newZoom) => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// Emit a picture every 10 seconds
|
||||
if (!isCameraMounted.value) {
|
||||
isCameraMounted.value = true;
|
||||
setInterval(() => {
|
||||
if (isCameraActive.value) {
|
||||
getFrame();
|
||||
}
|
||||
}, camera.getImageCaptureDelay(false)); // Implement a method to get the delay based on camera settings
|
||||
startCamera();
|
||||
}
|
||||
isCameraMounted.value = true;
|
||||
startCamera();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
+5
-5
@@ -82,11 +82,11 @@ export const REQUEST_QUEUE_CONFIG = Object.freeze({
|
||||
// Per-method concurrency limits
|
||||
concurrency: Object.freeze({
|
||||
GET: 10,
|
||||
POST: 1,
|
||||
PATCH: 1,
|
||||
PUT: 1,
|
||||
DELETE: 1,
|
||||
DEFAULT: 1,
|
||||
POST: 4,
|
||||
PATCH: 4,
|
||||
PUT: 4,
|
||||
DELETE: 4,
|
||||
DEFAULT: 4,
|
||||
}),
|
||||
// Delay between queue starts (0 = no pacing delay)
|
||||
spacingMs: 0,
|
||||
|
||||
@@ -38,7 +38,7 @@ const requestQueueStateMutable = reactive({
|
||||
});
|
||||
|
||||
const requestQueue = [];
|
||||
const activeWorkersByMethod = {};
|
||||
const activeWorkersByKey = {};
|
||||
let activeWorkers = 0;
|
||||
let requestIdCounter = 0;
|
||||
let drainTimer = null;
|
||||
@@ -71,6 +71,14 @@ const normalizeMethod = (value) => {
|
||||
return value.trim().toUpperCase();
|
||||
};
|
||||
|
||||
const normalizeQueueGroup = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.trim().toUpperCase();
|
||||
};
|
||||
|
||||
const normalizeUrl = (value) => {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return "(unknown endpoint)";
|
||||
@@ -231,10 +239,28 @@ const getMethodConcurrencyLimit = (method) => {
|
||||
return Math.max(1, Number(configuredLimit) || 1);
|
||||
};
|
||||
|
||||
const getJobConcurrencyKey = (job) => {
|
||||
const queueGroup = normalizeQueueGroup(job.queueGroup);
|
||||
if (queueGroup) {
|
||||
return `GROUP:${queueGroup}`;
|
||||
}
|
||||
|
||||
return `METHOD:${normalizeMethod(job.method)}`;
|
||||
};
|
||||
|
||||
const getJobConcurrencyLimit = (job) => {
|
||||
const configuredLimit = Number.parseInt(String(job.concurrencyLimit ?? ""), 10);
|
||||
if (Number.isInteger(configuredLimit) && configuredLimit > 0) {
|
||||
return configuredLimit;
|
||||
}
|
||||
|
||||
return getMethodConcurrencyLimit(job.method);
|
||||
};
|
||||
|
||||
const canRunJob = (job) => {
|
||||
const method = normalizeMethod(job.method);
|
||||
const activeForMethod = Number(activeWorkersByMethod[method] || 0);
|
||||
return activeForMethod < getMethodConcurrencyLimit(method);
|
||||
const concurrencyKey = getJobConcurrencyKey(job);
|
||||
const activeForKey = Number(activeWorkersByKey[concurrencyKey] || 0);
|
||||
return activeForKey < getJobConcurrencyLimit(job);
|
||||
};
|
||||
|
||||
const getNextRunnableJobIndex = () => {
|
||||
@@ -361,6 +387,7 @@ const upsertActiveRequest = (job, startedAt) => {
|
||||
const nextActive = [...requestQueueStateMutable.activeRequests, {
|
||||
id: job.id,
|
||||
method: normalizeMethod(job.method),
|
||||
queueGroup: normalizeQueueGroup(job.queueGroup) || null,
|
||||
url: job.url,
|
||||
queuedAt: job.enqueuedAt,
|
||||
startedAt,
|
||||
@@ -474,10 +501,12 @@ export const reportComponentMissingPermission = (permission, options = {}) => {
|
||||
|
||||
const runJob = (job) => {
|
||||
const method = normalizeMethod(job.method);
|
||||
const concurrencyKey = getJobConcurrencyKey(job);
|
||||
const queueGroup = normalizeQueueGroup(job.queueGroup) || null;
|
||||
const startedAt = Date.now();
|
||||
|
||||
activeWorkers += 1;
|
||||
activeWorkersByMethod[method] = Number(activeWorkersByMethod[method] || 0) + 1;
|
||||
activeWorkersByKey[concurrencyKey] = Number(activeWorkersByKey[concurrencyKey] || 0) + 1;
|
||||
lastRequestStartedAt = startedAt;
|
||||
upsertActiveRequest(job, startedAt);
|
||||
syncQueueCounters();
|
||||
@@ -490,6 +519,7 @@ const runJob = (job) => {
|
||||
pushRecentRequest({
|
||||
id: job.id,
|
||||
method,
|
||||
queueGroup,
|
||||
url: job.url,
|
||||
success: true,
|
||||
statusCode: getResponseStatusCode(response),
|
||||
@@ -524,6 +554,7 @@ const runJob = (job) => {
|
||||
pushRecentRequest({
|
||||
id: job.id,
|
||||
method,
|
||||
queueGroup,
|
||||
url: job.url,
|
||||
success: false,
|
||||
statusCode,
|
||||
@@ -537,6 +568,7 @@ const runJob = (job) => {
|
||||
pushErrorRequest({
|
||||
id: job.id,
|
||||
method,
|
||||
queueGroup,
|
||||
url: job.url,
|
||||
statusCode,
|
||||
attemptCount: Math.max(1, Number(error?.__queueAttemptCount) || 1),
|
||||
@@ -573,9 +605,9 @@ const runJob = (job) => {
|
||||
})
|
||||
.finally(() => {
|
||||
activeWorkers -= 1;
|
||||
activeWorkersByMethod[method] = Math.max(0, Number(activeWorkersByMethod[method] || 1) - 1);
|
||||
if (activeWorkersByMethod[method] === 0) {
|
||||
delete activeWorkersByMethod[method];
|
||||
activeWorkersByKey[concurrencyKey] = Math.max(0, Number(activeWorkersByKey[concurrencyKey] || 1) - 1);
|
||||
if (activeWorkersByKey[concurrencyKey] === 0) {
|
||||
delete activeWorkersByKey[concurrencyKey];
|
||||
}
|
||||
removeActiveRequest(job.id);
|
||||
syncQueueCounters();
|
||||
@@ -625,6 +657,8 @@ export const enqueueRequest = (requestFactory, options = {}) => {
|
||||
requestData: options.requestData || null,
|
||||
retryByStatusCode: options.retryByStatusCode || null,
|
||||
shouldRetry: typeof options.shouldRetry === "function" ? options.shouldRetry : null,
|
||||
queueGroup: normalizeQueueGroup(options.queueGroup),
|
||||
concurrencyLimit: options.concurrencyLimit || null,
|
||||
});
|
||||
requestQueueStateMutable.batchTotal += 1;
|
||||
syncQueueCounters();
|
||||
@@ -649,7 +683,7 @@ export const __resetRequestQueueForTests = () => {
|
||||
requestQueue.length = 0;
|
||||
activeWorkers = 0;
|
||||
requestIdCounter = 0;
|
||||
Object.keys(activeWorkersByMethod).forEach((method) => delete activeWorkersByMethod[method]);
|
||||
Object.keys(activeWorkersByKey).forEach((key) => delete activeWorkersByKey[key]);
|
||||
lastRequestStartedAt = 0;
|
||||
clearDrainTimer();
|
||||
|
||||
|
||||
@@ -238,4 +238,158 @@ describe("authenticatedRequest", () => {
|
||||
expect(requestQueueState.batchCompleted).toBe(3);
|
||||
expect(requestQueueState.batchFailed).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps self-serve hardware commands serial without blocking ordinary POST requests", async () => {
|
||||
const hardwareOne = createDeferred();
|
||||
const hardwareTwo = createDeferred();
|
||||
const ordinaryPost = createDeferred();
|
||||
axiosMock.mockImplementation(({ url }) => {
|
||||
if (url.includes("/modules/self-serve/lane/command")) {
|
||||
return hardwareOne.promise;
|
||||
}
|
||||
if (url.includes("/modules/self-serve/lane/relay/machine/enable")) {
|
||||
return hardwareTwo.promise;
|
||||
}
|
||||
if (url.endsWith("/orders")) {
|
||||
return ordinaryPost.promise;
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
|
||||
});
|
||||
|
||||
const request1 = authenticatedRequest("/modules/self-serve/lane/command", "post", {
|
||||
lane_id: 7,
|
||||
command: "STOP",
|
||||
});
|
||||
const request2 = authenticatedRequest("/modules/self-serve/lane/relay/machine/enable", "post", {
|
||||
lane_id: 7,
|
||||
});
|
||||
const request3 = authenticatedRequest("/orders", "post", {
|
||||
reference: "ordinary-post",
|
||||
});
|
||||
|
||||
await flushManyMicrotasks();
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(2);
|
||||
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
|
||||
expect.stringContaining("/modules/self-serve/lane/command"),
|
||||
expect.stringMatching(/\/orders$/),
|
||||
]);
|
||||
expect(requestQueueState.active).toBe(2);
|
||||
expect(requestQueueState.pending).toBe(1);
|
||||
|
||||
ordinaryPost.resolve({ status: 200, data: { id: 1 } });
|
||||
await flushManyMicrotasks();
|
||||
expect(axiosMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
hardwareOne.resolve({ status: 200, data: { ok: true } });
|
||||
await flushManyMicrotasks();
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(3);
|
||||
expect(axiosMock.mock.calls[2][0].url).toContain("/modules/self-serve/lane/relay/machine/enable");
|
||||
|
||||
hardwareTwo.resolve({ status: 200, data: { ok: true } });
|
||||
await expect(Promise.all([request1, request2, request3])).resolves.toHaveLength(3);
|
||||
});
|
||||
|
||||
it("keeps POS scanner and Stripe invoice requests from blocking ordinary POS order mutations", async () => {
|
||||
const scannerOne = createDeferred();
|
||||
const scannerTwo = createDeferred();
|
||||
const stripeInvoice = createDeferred();
|
||||
const orderCreate = createDeferred();
|
||||
const scannerResponses = [scannerOne, scannerTwo];
|
||||
axiosMock.mockImplementation(({ url }) => {
|
||||
if (url.includes("/modules/scanner/lpr")) {
|
||||
return scannerResponses.shift()?.promise;
|
||||
}
|
||||
if (url.includes("/modules/stripe/invoice")) {
|
||||
return stripeInvoice.promise;
|
||||
}
|
||||
if (url.endsWith("/orders")) {
|
||||
return orderCreate.promise;
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
|
||||
});
|
||||
|
||||
const request1 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-one" });
|
||||
const request2 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-two" });
|
||||
const request3 = authenticatedRequest("/modules/stripe/invoice", "post", { order_id: 42, email: "a@example.test" });
|
||||
const request4 = authenticatedRequest("/orders", "post", { department_id: 3, reference: "pos-order" });
|
||||
|
||||
await flushManyMicrotasks();
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(4);
|
||||
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
|
||||
expect.stringContaining("/modules/scanner/lpr"),
|
||||
expect.stringContaining("/modules/scanner/lpr"),
|
||||
expect.stringContaining("/modules/stripe/invoice"),
|
||||
expect.stringMatching(/\/orders$/),
|
||||
]);
|
||||
expect(requestQueueState.active).toBe(4);
|
||||
expect(requestQueueState.pending).toBe(0);
|
||||
expect(requestQueueState.activeRequests.map((request) => request.queueGroup)).toEqual([
|
||||
"POS_SCANNER",
|
||||
"POS_SCANNER",
|
||||
"POS_STRIPE",
|
||||
null,
|
||||
]);
|
||||
|
||||
scannerOne.resolve({ status: 200, data: { success: true } });
|
||||
scannerTwo.resolve({ status: 200, data: { success: true } });
|
||||
stripeInvoice.resolve({ status: 200, data: { id: "in_1" } });
|
||||
orderCreate.resolve({ status: 200, data: { id: 42 } });
|
||||
|
||||
await expect(Promise.all([request1, request2, request3, request4])).resolves.toHaveLength(4);
|
||||
});
|
||||
|
||||
it("does not retry POS Stripe invoice mutations", async () => {
|
||||
__configureRequestQueueForTests({
|
||||
retryByStatusCode: { 500: 1 },
|
||||
retryDelayBaseMs: 0,
|
||||
retryDelayMaxMs: 0,
|
||||
retryDelayJitterMs: 0,
|
||||
});
|
||||
axiosMock.mockRejectedValue({
|
||||
response: {
|
||||
status: 500,
|
||||
data: { message: "temporary Stripe failure" },
|
||||
},
|
||||
message: "Request failed",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authenticatedRequest("/modules/stripe/invoice", "post", {
|
||||
order_id: 42,
|
||||
email: "a@example.test",
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 500 } });
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
expect(requestQueueState.batchFailed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not retry non-idempotent self-serve hardware mutations", async () => {
|
||||
__configureRequestQueueForTests({
|
||||
retryByStatusCode: { 500: 1 },
|
||||
retryDelayBaseMs: 0,
|
||||
retryDelayMaxMs: 0,
|
||||
retryDelayJitterMs: 0,
|
||||
});
|
||||
axiosMock.mockRejectedValue({
|
||||
response: {
|
||||
status: 500,
|
||||
data: { message: "temporary backend failure" },
|
||||
},
|
||||
message: "Request failed",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authenticatedRequest("/modules/self-serve/lane/command", "post", {
|
||||
lane_id: 7,
|
||||
command: "STOP",
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 500 } });
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
expect(requestQueueState.batchFailed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,6 +228,44 @@ describe("axios request queue interceptor", () => {
|
||||
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
it("allows multiple ordinary POST requests by default to use available PHP workers", async () => {
|
||||
__resetAxiosRequestQueueInstallerForTests();
|
||||
__resetRequestQueueForTests();
|
||||
__resetReleaseTimelineForTests();
|
||||
installAxiosRequestQueue();
|
||||
|
||||
const allDeferred = Array.from({ length: 5 }, () => createDeferred());
|
||||
const pendingAdapters = [...allDeferred];
|
||||
const adapter = () => pendingAdapters.shift()?.promise;
|
||||
|
||||
const requests = Array.from({ length: 5 }, (_, index) =>
|
||||
axios({
|
||||
url: `/queue-post-${index + 1}`,
|
||||
method: "POST",
|
||||
adapter,
|
||||
})
|
||||
);
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(requestQueueState.active).toBe(4);
|
||||
expect(requestQueueState.pending).toBe(1);
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
allDeferred[index].resolve(createResponse({ id: index + 1 }));
|
||||
}
|
||||
|
||||
await flushManyMicrotasks();
|
||||
|
||||
expect(requestQueueState.active).toBe(1);
|
||||
expect(requestQueueState.pending).toBe(0);
|
||||
|
||||
allDeferred[4].resolve(createResponse({ id: 5 }));
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("retries configured response status codes", async () => {
|
||||
__configureRequestQueueForTests({
|
||||
maxConcurrentGet: 1,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushPromises } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
import PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
request: vi.fn(),
|
||||
setTransparency: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setOverflow: vi.fn(),
|
||||
cameraSetLatestImage: vi.fn(),
|
||||
cameraSetLastSuccess: vi.fn(),
|
||||
cameraHasDelayAfterSuccessPassed: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/page/headers/ViewportHeaderSettings.vue", () => ({
|
||||
backgroundColors: {
|
||||
default: "default",
|
||||
},
|
||||
setBackgroundColor: mocks.setBackgroundColor,
|
||||
setOverflow: mocks.setOverflow,
|
||||
setTransparency: mocks.setTransparency,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
request: mocks.request,
|
||||
objects: {
|
||||
global: {
|
||||
language: {
|
||||
scanning: "Scanning",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => {
|
||||
const manualInput = ref(false);
|
||||
const transactionHistoryView = ref(false);
|
||||
const activeVehicleIndex = ref(1);
|
||||
const activeVehicle = ref({ reg: "" });
|
||||
const latestImage = ref(null);
|
||||
|
||||
return {
|
||||
attachments: {
|
||||
base64: ref([]),
|
||||
},
|
||||
camera: {
|
||||
latestImage,
|
||||
setLatestImage: mocks.cameraSetLatestImage,
|
||||
hasDelayAfterSuccessPassed: mocks.cameraHasDelayAfterSuccessPassed,
|
||||
setLastSuccess: mocks.cameraSetLastSuccess,
|
||||
},
|
||||
manualInput,
|
||||
sounds: {
|
||||
list: ref({ onAfterSuccessfulScan: "scan" }),
|
||||
play: vi.fn(),
|
||||
},
|
||||
transactionHistoryView,
|
||||
vehicles: {
|
||||
activeVehicleIndex,
|
||||
getActiveVehicle: () => activeVehicle.value,
|
||||
select: vi.fn(),
|
||||
setActiveVehicleIndex: vi.fn((nextIndex) => {
|
||||
activeVehicleIndex.value = nextIndex;
|
||||
}),
|
||||
vehicle_1: activeVehicle,
|
||||
vehicle_2: ref({ reg: "" }),
|
||||
vehicle_3: ref({ reg: "" }),
|
||||
},
|
||||
views: {
|
||||
attachmentView: ref(false),
|
||||
isAnyActive: ref(false),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerCamera.vue", () => ({
|
||||
default: {
|
||||
name: "ScannerCamera",
|
||||
emits: ["update:frame"],
|
||||
template: `
|
||||
<div>
|
||||
<button data-testid="camera-frame-a" @click="$emit('update:frame', 'frame-a')" />
|
||||
<button data-testid="camera-frame-b" @click="$emit('update:frame', 'frame-b')" />
|
||||
<button data-testid="camera-frame-c" @click="$emit('update:frame', 'frame-c')" />
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerInstructions.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerOutline.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/models/pos/step1/RegistrationNumberSearchResult.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileManualInput.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue",
|
||||
() => ({
|
||||
default: { template: "<div><slot /></div>" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/UnknownCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/VerifiedCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/BookedCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/KnownCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/CardPaymentCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
const resolveImageImmediately = () => {
|
||||
class TestImage {
|
||||
onerror = null;
|
||||
|
||||
set src(_value) {
|
||||
this.onerror?.(new Error("skip image decode"));
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("Image", TestImage);
|
||||
};
|
||||
|
||||
describe("POS mobile camera LPR", () => {
|
||||
beforeEach(() => {
|
||||
mocks.request.mockReset();
|
||||
mocks.cameraSetLatestImage.mockReset();
|
||||
mocks.cameraSetLastSuccess.mockReset();
|
||||
mocks.cameraHasDelayAfterSuccessPassed.mockReset();
|
||||
mocks.cameraHasDelayAfterSuccessPassed.mockReturnValue(true);
|
||||
resolveImageImmediately();
|
||||
});
|
||||
|
||||
it("does not enqueue overlapping scanner requests while a camera frame is still being parsed", async () => {
|
||||
let resolveFirstRequest;
|
||||
mocks.request.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirstRequest = resolve;
|
||||
})
|
||||
);
|
||||
mocks.request.mockResolvedValue({ data: { success: false } });
|
||||
|
||||
const wrapper = mountWithApp(PosDepartmentStepMobile1);
|
||||
|
||||
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(mocks.request).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.request).toHaveBeenLastCalledWith("/modules/scanner/lpr", "POST", {
|
||||
base64_image: "frame-a",
|
||||
});
|
||||
|
||||
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(mocks.request).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFirstRequest({ data: { success: false } });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(mocks.request).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.request).toHaveBeenLastCalledWith("/modules/scanner/lpr", "POST", {
|
||||
base64_image: "frame-c",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user