Files
pleno-vue/tests/unit/economic-transfer-queue.spec.js
T

863 lines
24 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useEconomicQueueJob } from "@/composables/useEconomicQueueJob.js";
import {
buildCollectedInvoiceEconomicPayload,
calculateEconomicTransferQueueProgress,
dismissEconomicTransferQueueJob,
dismissTerminalEconomicTransferQueueJobs,
fetchEconomicTransferQueueMonitor,
getEconomicQueueMonitorPollIntervalMs,
isCollectedInvoiceQueueMonitorRoute,
parseEconomicTransferQueueListResponse,
parseEconomicTransferQueueMonitorResponse,
parseQueueJobFromEnqueueResponse,
parseQueueRunResponse,
parseQueueJobFromRetryResponse,
} from "@/services/economicTransferQueue.js";
const createQueueJobResponse = (job) => ({
data: {
data: {
job,
},
},
});
const createQueueStatusResponse = (job) => ({
data: {
data: job,
},
});
describe("economic transfer queue workflow", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("handles enqueue success then polling to completed", async () => {
vi.useFakeTimers();
const requestFn = vi.fn();
let statusCallCount = 0;
requestFn.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === "/economic/invoice/export" && method === "POST") {
expect(payload).toEqual({ order_id: 91 });
return createQueueJobResponse({
id: 1001,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued for export",
});
}
if (endpoint === "/economic/invoice/export/status" && method === "GET") {
expect(payload).toEqual({ job_id: 1001 });
statusCallCount += 1;
if (statusCallCount === 1) {
return createQueueStatusResponse({
id: 1001,
status: "PROCESSING",
progress_percent: 35,
progress_message: "Collecting invoice data",
});
}
return createQueueStatusResponse({
id: 1001,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Completed",
result: {
message: "Invoice exported",
},
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/economic/invoice/export",
statusEndpoint: "/economic/invoice/export/status",
retryEndpoint: "/economic/invoice/export/retry",
requestFn,
pollIntervalMs: 250,
buildEnqueuePayload: (payload) => ({
order_id: Number.parseInt(String(payload.order_id), 10),
}),
});
const enqueueResult = await queue.enqueue({ order_id: 91 });
expect(enqueueResult.id).toBe(1001);
expect(queue.status.value).toBe("PROCESSING");
expect(queue.isQueuedOrProcessing.value).toBe(true);
expect(queue.disableSubmit.value).toBe(true);
expect(queue.transportErrorMessage.value).toBe("");
await vi.advanceTimersByTimeAsync(250);
expect(queue.status.value).toBe("COMPLETED");
expect(queue.isCompleted.value).toBe(true);
expect(queue.isQueuedOrProcessing.value).toBe(false);
expect(queue.result.value).toEqual({ message: "Invoice exported" });
expect(queue.userMessage.value).toBe("Invoice exported");
});
it("handles enqueue success then failed status with retry to completed", async () => {
vi.useFakeTimers();
const requestFn = vi.fn();
let phase = "initial";
let initialStatusCalls = 0;
let retryStatusCalls = 0;
requestFn.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === "/economic/invoice/draft/export" && method === "POST") {
expect(payload).toEqual({ order_id: 332 });
return createQueueJobResponse({
id: 2002,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
});
}
if (endpoint === "/economic/invoice/draft/export/retry" && method === "POST") {
expect(payload).toEqual({ job_id: 2002 });
phase = "retry";
return createQueueJobResponse({
id: 2002,
status: "QUEUED",
progress_percent: 0,
progress_message: "Retry queued",
});
}
if (endpoint === "/economic/invoice/draft/export/status" && method === "GET") {
expect(payload).toEqual({ job_id: 2002 });
if (phase === "initial") {
initialStatusCalls += 1;
if (initialStatusCalls === 1) {
return createQueueStatusResponse({
id: 2002,
status: "FAILED",
progress_percent: 10,
progress_message: "Validation failed",
error_message: "no billable order items",
});
}
}
retryStatusCalls += 1;
if (retryStatusCalls === 1) {
return createQueueStatusResponse({
id: 2002,
status: "PROCESSING",
progress_percent: 70,
progress_message: "Retry processing",
});
}
return createQueueStatusResponse({
id: 2002,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Retry completed",
result: {
message: "Draft exported",
},
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/economic/invoice/draft/export",
statusEndpoint: "/economic/invoice/draft/export/status",
retryEndpoint: "/economic/invoice/draft/export/retry",
requestFn,
pollIntervalMs: 200,
buildEnqueuePayload: (payload) => ({
order_id: Number.parseInt(String(payload.order_id), 10),
}),
});
await queue.enqueue({ order_id: 332 });
expect(queue.status.value).toBe("FAILED");
expect(queue.isFailed.value).toBe(true);
expect(queue.queueFailureMessage.value).toBe("no billable order items");
expect(queue.canRetry.value).toBe(true);
await queue.retry();
expect(queue.status.value).toBe("PROCESSING");
expect(queue.disableSubmit.value).toBe(true);
await vi.advanceTimersByTimeAsync(200);
expect(queue.status.value).toBe("COMPLETED");
expect(queue.isCompleted.value).toBe(true);
expect(queue.result.value).toEqual({ message: "Draft exported" });
});
it("enforces strict boolean send_as_is in collected invoice payload", () => {
expect(() =>
buildCollectedInvoiceEconomicPayload({
id: 123,
send_as_is: "true",
})
).toThrow("send_as_is must be a boolean.");
expect(
buildCollectedInvoiceEconomicPayload({
id: 123,
send_as_is: true,
})
).toEqual({
id: 123,
send_as_is: true,
});
});
it("parses queue run responses with counts, jobs, and batch limit", () => {
const run = parseQueueRunResponse({
data: {
data: {
message: "Collected invoice queue batch processed",
processed: 2,
completed: 1,
failed: 1,
jobs: [4001, "4002", "invalid"],
limit: 10,
transfer_type: "COLLECTED_INVOICE_EXPORT",
},
},
});
expect(run).toEqual({
message: "Collected invoice queue batch processed",
processed: 2,
completed: 1,
failed: 1,
jobs: [4001, 4002],
limit: 10,
transfer_type: "COLLECTED_INVOICE_EXPORT",
fallback: false,
});
});
it("parses monitor responses with status counts and queue progress", () => {
const monitor = parseEconomicTransferQueueMonitorResponse({
data: {
data: {
jobs: [
{ id: 501, status: "QUEUED", progress_percent: 0 },
{ id: 502, status: "PROCESSING", progress_percent: 50 },
{ id: 503, status: "FAILED", progress_percent: 10 },
{ id: 504, status: "COMPLETED", progress_percent: 100 },
],
counts: {
queued: 1,
in_progress: 1,
failed: 1,
completed: 1,
total: 4,
},
progress_percent: 63,
limit: 50,
},
},
});
expect(monitor.counts).toEqual({
queued: 1,
in_progress: 1,
failed: 1,
completed: 1,
total: 4,
});
expect(monitor.progress_percent).toBe(63);
expect(monitor.jobs.map((job) => job.status)).toEqual(["QUEUED", "PROCESSING", "FAILED", "COMPLETED"]);
});
it("calculates monitor progress when the backend omits aggregate progress", () => {
expect(
calculateEconomicTransferQueueProgress([
{ status: "QUEUED", progress_percent: 0 },
{ status: "PROCESSING", progress_percent: 50 },
{ status: "FAILED", progress_percent: 0 },
{ status: "COMPLETED", progress_percent: 100 },
])
).toBe(63);
expect(
parseEconomicTransferQueueMonitorResponse({
data: {
data: {
jobs: [
{ id: 601, status: "QUEUED", progress_percent: 0 },
{ id: 602, status: "PROCESSING", progress_percent: 25 },
],
},
},
}).counts
).toMatchObject({
queued: 1,
in_progress: 1,
total: 2,
});
});
it("uses active and idle polling cadences for queue monitor state", () => {
expect(
getEconomicQueueMonitorPollIntervalMs({
counts: {
queued: 1,
in_progress: 0,
},
})
).toBe(4000);
expect(
getEconomicQueueMonitorPollIntervalMs({
counts: {
queued: 0,
in_progress: 0,
failed: 1,
completed: 1,
},
})
).toBe(30000);
});
it("gates the queue monitor to superuser invoice routes", () => {
expect(isCollectedInvoiceQueueMonitorRoute("/superuser/invoices")).toBe(true);
expect(isCollectedInvoiceQueueMonitorRoute("/superuser/invoices/123")).toBe(true);
expect(isCollectedInvoiceQueueMonitorRoute("/superuser/invoices/distribution/2026/5")).toBe(true);
expect(isCollectedInvoiceQueueMonitorRoute("/superuser/customers")).toBe(false);
expect(isCollectedInvoiceQueueMonitorRoute("/admin/1/orders")).toBe(false);
});
it("requests monitor and clear endpoints with the expected payloads", async () => {
const requestFn = vi.fn();
requestFn.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === "/collected-invoices/economic/queue/monitor" && method === "GET") {
expect(payload).toEqual({ limit: 25 });
return {
data: {
data: {
jobs: [],
counts: {
queued: 0,
in_progress: 0,
failed: 0,
completed: 0,
total: 0,
},
progress_percent: 0,
limit: 25,
},
},
};
}
if (endpoint === "/collected-invoices/economic/queue/dismiss" && method === "POST") {
expect(payload).toEqual({ job_id: 7001 });
return { data: { data: { message: "cleared" } } };
}
if (endpoint === "/collected-invoices/economic/queue/dismiss-terminal" && method === "POST") {
expect(payload).toEqual({});
return { data: { data: { dismissed_count: 2 } } };
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const monitorResult = await fetchEconomicTransferQueueMonitor({
limit: 25,
requestFn,
});
await dismissEconomicTransferQueueJob({
jobId: 7001,
requestFn,
});
await dismissTerminalEconomicTransferQueueJobs({
requestFn,
});
expect(monitorResult.monitor.limit).toBe(25);
expect(requestFn).toHaveBeenCalledTimes(3);
});
it("stops polling after dispose (unmount/navigation cancellation)", async () => {
vi.useFakeTimers();
const requestFn = vi.fn();
let statusCallCount = 0;
requestFn.mockImplementation(async (endpoint, method) => {
if (endpoint === "/collected-invoices/economic" && method === "POST") {
return createQueueJobResponse({
id: 3003,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
});
}
if (endpoint === "/collected-invoices/economic/queue/status" && method === "GET") {
statusCallCount += 1;
return createQueueStatusResponse({
id: 3003,
status: "PROCESSING",
progress_percent: 50,
progress_message: "Working",
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/collected-invoices/economic",
statusEndpoint: "/collected-invoices/economic/queue/status",
retryEndpoint: "/collected-invoices/economic/queue/retry",
requestFn,
pollIntervalMs: 300,
buildEnqueuePayload: (payload) => buildCollectedInvoiceEconomicPayload(payload),
});
await queue.enqueue({
id: 44,
send_as_is: false,
});
expect(statusCallCount).toBe(1);
expect(queue.isQueuedOrProcessing.value).toBe(true);
queue.dispose();
await vi.advanceTimersByTimeAsync(1200);
expect(statusCallCount).toBe(1);
expect(queue.isPolling.value).toBe(false);
});
it("accepts enqueue responses that only include data.job_id", async () => {
const requestFn = vi.fn();
requestFn.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === "/collected-invoices/economic" && method === "POST") {
expect(payload).toEqual({
id: 55,
send_as_is: false,
});
return {
data: {
data: {
message: "Queued for transfer",
job_id: 4111,
job: {
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
},
},
},
};
}
if (endpoint === "/collected-invoices/economic/queue/status" && method === "GET") {
expect(payload).toEqual({ job_id: 4111 });
return createQueueStatusResponse({
id: 4111,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Completed",
result: {
message: "Transferred",
},
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/collected-invoices/economic",
statusEndpoint: "/collected-invoices/economic/queue/status",
retryEndpoint: "/collected-invoices/economic/queue/retry",
requestFn,
pollIntervalMs: 250,
buildEnqueuePayload: (payload) => buildCollectedInvoiceEconomicPayload(payload),
});
await queue.enqueue({
id: 55,
send_as_is: false,
});
expect(queue.jobId.value).toBe(4111);
expect(queue.status.value).toBe("COMPLETED");
expect(queue.result.value).toEqual({
message: "Transferred",
});
});
it("accepts retry responses with job.job_id alias", () => {
const job = parseQueueJobFromRetryResponse({
data: {
data: {
job: {
job_id: "8122",
status: "QUEUED",
progress_percent: 0,
progress_message: "Retry queued",
},
},
},
});
expect(job).toEqual({
id: 8122,
job_id: "8122",
status: "QUEUED",
progress_percent: 0,
progress_message: "Retry queued",
error_message: null,
result: null,
});
});
it("still throws when enqueue response has no queue job id", () => {
expect(() =>
parseQueueJobFromEnqueueResponse({
data: {
data: {
message: "Queued",
job: {
status: "QUEUED",
progress_percent: 0,
},
},
},
})
).toThrow("Missing queue job id in enqueue response.");
});
it("parses additive queue list metadata fields from list responses", () => {
const parsed = parseEconomicTransferQueueListResponse(
{
data: {
data: {
items: [
{
id: 7001,
status: "PROCESSING",
progress_percent: 30,
progress_message: "Exporting",
attempts: 1,
max_attempts: 3,
created_at: "2026-03-10T10:00:00Z",
},
],
count: 1,
total: 8,
limit: 1,
offset: 2,
has_more: true,
},
},
},
{
defaultLimit: 50,
defaultOffset: 0,
}
);
expect(parsed.items).toHaveLength(1);
expect(parsed.items[0]).toMatchObject({
id: 7001,
status: "PROCESSING",
attempts: 1,
max_attempts: 3,
});
expect(parsed.count).toBe(1);
expect(parsed.total).toBe(8);
expect(parsed.limit).toBe(1);
expect(parsed.offset).toBe(2);
expect(parsed.hasMore).toBe(true);
});
it("keeps additive details_summary payloads intact when parsing queue list responses", () => {
const parsed = parseEconomicTransferQueueListResponse({
data: {
data: {
items: [
{
id: 7401,
status: "COMPLETED",
progress_percent: 100,
details_summary: {
message: "Transferred",
target: {
collected_invoice_id: 501,
send_as_is: false,
requested_by: 7,
},
customer: {
customer_number: 43425425,
name: "Carrier A/S",
},
outcome: {
economic_invoice_draft_id: 91,
economic_invoice_booked_id: 92,
external_id: "ext-92",
total_net_amount: 670,
order_count: 3,
},
raw_available: {
payload: true,
result: true,
},
},
},
],
},
},
});
expect(parsed.items).toHaveLength(1);
expect(parsed.items[0].details_summary).toEqual({
message: "Transferred",
target: {
collected_invoice_id: 501,
send_as_is: false,
requested_by: 7,
},
customer: {
customer_number: 43425425,
name: "Carrier A/S",
},
outcome: {
economic_invoice_draft_id: 91,
economic_invoice_booked_id: 92,
external_id: "ext-92",
total_net_amount: 670,
order_count: 3,
},
raw_available: {
payload: true,
result: true,
},
});
});
it("derives hasMore deterministically when metadata flag is omitted", () => {
const parsed = parseEconomicTransferQueueListResponse({
data: {
data: {
items: [
{ id: 7101, status: "FAILED" },
{ id: 7102, status: "FAILED" },
],
count: 2,
total: 5,
limit: 2,
offset: 2,
},
},
});
expect(parsed.hasMore).toBe(true);
const tailPage = parseEconomicTransferQueueListResponse({
data: {
data: {
items: [{ id: 7103, status: "COMPLETED" }],
count: 1,
total: 5,
limit: 2,
offset: 4,
},
},
});
expect(tailPage.hasMore).toBe(false);
});
it("guards retry when current queue status is not FAILED", async () => {
const requestFn = vi.fn();
requestFn.mockImplementation(async (endpoint, method) => {
if (endpoint === "/economic/invoice/export" && method === "POST") {
return createQueueJobResponse({
id: 8201,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
});
}
if (endpoint === "/economic/invoice/export/status" && method === "GET") {
return createQueueStatusResponse({
id: 8201,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Completed",
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/economic/invoice/export",
statusEndpoint: "/economic/invoice/export/status",
retryEndpoint: "/economic/invoice/export/retry",
requestFn,
pollIntervalMs: 200,
buildEnqueuePayload: (payload) => payload,
});
await queue.enqueue({ order_id: 11 });
expect(queue.status.value).toBe("COMPLETED");
await expect(queue.retry()).rejects.toThrow("Retry is only allowed for failed queue jobs.");
});
it("guards retry when failed job has reached max attempts", async () => {
const requestFn = vi.fn();
requestFn.mockImplementation(async (endpoint, method) => {
if (endpoint === "/economic/invoice/export" && method === "POST") {
return createQueueJobResponse({
id: 8202,
status: "FAILED",
progress_percent: 100,
progress_message: "Failed",
error_message: "No line items",
attempts: 3,
max_attempts: 3,
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/economic/invoice/export",
statusEndpoint: "/economic/invoice/export/status",
retryEndpoint: "/economic/invoice/export/retry",
requestFn,
pollIntervalMs: 200,
buildEnqueuePayload: (payload) => payload,
});
await queue.enqueue({ order_id: 12 });
expect(queue.status.value).toBe("FAILED");
expect(queue.canRetry.value).toBe(false);
await expect(queue.retry()).rejects.toThrow("Retry is not allowed because max attempts were reached.");
});
it("stops polling on transport error and supports a clean polling restart", async () => {
vi.useFakeTimers();
const requestFn = vi.fn();
let mode = "fail";
let statusCalls = 0;
requestFn.mockImplementation(async (endpoint, method) => {
if (endpoint === "/collected-invoices/economic" && method === "POST") {
return createQueueJobResponse({
id: mode === "fail" ? 9101 : 9102,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
});
}
if (endpoint === "/collected-invoices/economic/queue/status" && method === "GET") {
if (mode === "fail") {
throw {
response: {
data: {
data: {
message: "gateway timeout",
},
},
},
};
}
statusCalls += 1;
if (statusCalls === 1) {
return createQueueStatusResponse({
id: 9102,
status: "PROCESSING",
progress_percent: 55,
progress_message: "Working",
});
}
return createQueueStatusResponse({
id: 9102,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Completed",
result: {
message: "Recovered",
},
});
}
throw new Error(`Unexpected request ${method} ${endpoint}`);
});
const queue = useEconomicQueueJob({
enqueueEndpoint: "/collected-invoices/economic",
statusEndpoint: "/collected-invoices/economic/queue/status",
retryEndpoint: "/collected-invoices/economic/queue/retry",
requestFn,
pollIntervalMs: 250,
buildEnqueuePayload: (payload) => buildCollectedInvoiceEconomicPayload(payload),
});
await expect(
queue.enqueue({
id: 91,
send_as_is: false,
})
).rejects.toBeDefined();
expect(queue.transportErrorMessage.value).toContain("gateway timeout");
expect(queue.isPolling.value).toBe(false);
mode = "success";
await queue.enqueue({
id: 92,
send_as_is: false,
});
expect(queue.transportErrorMessage.value).toBe("");
expect(queue.status.value).toBe("PROCESSING");
await vi.advanceTimersByTimeAsync(250);
expect(queue.status.value).toBe("COMPLETED");
expect(queue.result.value).toEqual({ message: "Recovered" });
});
});