Planning merge conflict resolution

This commit is contained in:
copilot-swe-agent[bot]
2026-06-01 22:14:58 +00:00
committed by GitHub
7 changed files with 260 additions and 29 deletions
+5 -5
View File
@@ -16,8 +16,8 @@ concurrency:
jobs:
format-tests:
# Match the labels exposed by the Coolify-managed GitHub runner.
runs-on: [self-hosted, Linux, X64, default]
# Pull requests run untrusted code, so use ephemeral GitHub-hosted runners.
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -39,7 +39,7 @@ jobs:
build-and-unit:
needs: format-tests
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -64,7 +64,7 @@ jobs:
e2e-pr:
if: github.event_name != 'schedule'
needs: build-and-unit
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -122,7 +122,7 @@ jobs:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
needs: build-and-unit
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
@@ -15,6 +15,7 @@ import {
selectPreferredStripeTerminalReaderId,
STRIPE_TERMINAL_STATUS,
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
const POLLING_INTERVAL_MS = 5000;
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
@@ -87,29 +88,6 @@ const selectedTaxRate = ref(1);
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
const normalizeStripeInvoice = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
return {
id: value.id ?? props.order_id,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: Boolean(value.paid),
status: value.status || 'unknown',
amount_due: Number(value.amount_due ?? 0),
amount_paid: Number(value.amount_paid ?? 0),
};
};
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
const isStripeEmailInvoiceTerminalState = computed(() => {
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
try {
const response = await getOrder(props.order_id, true);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
stripeInvoice.value = nextInvoice;
if (nextInvoice) {
emailPanelState.value = 'tracking';
@@ -0,0 +1,60 @@
const STRIPE_INVOICE_PAID_STATUS = 'paid';
export const parseStripeInvoicePaidFlag = (value) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['true', '1'].includes(normalizedValue)) {
return true;
}
if (['false', '0', ''].includes(normalizedValue)) {
return false;
}
}
return false;
};
const toFiniteNumber = (value, fallback = 0) => {
const parsedValue = Number(value ?? fallback);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
};
export const normalizeStripeInvoice = (value, fallbackOrderId = null) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
const status = String(value.status || 'unknown').toLowerCase();
const amountDue = toFiniteNumber(value.amount_due);
const amountPaid = toFiniteNumber(value.amount_paid);
const hasCoveredAmountDue = amountDue <= 0 || amountPaid >= amountDue;
const isPaid = status === STRIPE_INVOICE_PAID_STATUS
&& parseStripeInvoicePaidFlag(value.paid)
&& hasCoveredAmountDue;
return {
id: value.id ?? fallbackOrderId,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: isPaid,
status,
amount_due: amountDue,
amount_paid: amountPaid,
};
};
@@ -2,6 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import {
cancelEdgeGatewayOperation,
clearEdgeGatewayWorkspaceCache,
createEdgeGatewayInstallToken,
createEdgeGatewayOperation,
deleteEdgeGateway,
@@ -12,10 +13,12 @@ import {
getEdgeGatewayStatistics,
getEdgeGatewayTasks,
listEdgeGatewayDepartments,
isEdgeGatewayAuthorizationError,
listEdgeGateways,
peekCachedEdgeGateway,
peekCachedEdgeGatewayDepartments,
peekCachedEdgeGatewayList,
removeEdgeGatewayCache,
rotateEdgeGatewayCredentials,
saveEdgeGatewayBindings,
setDepartmentGatewayCutover,
@@ -1215,6 +1218,15 @@ const refreshSelected = async (gatewayId = activeGatewayId.value, { forceRefresh
}
return gateway;
} catch (error) {
if (isEdgeGatewayAuthorizationError(error)) {
removeEdgeGatewayCache(gatewayId);
unavailableGatewayId.value = String(gatewayId);
selectedGateway.value = null;
resetGatewayViewSnapshots();
fail(error);
return null;
}
const fallbackGateway = findLocalGatewaySnapshot(gatewayId);
if (fallbackGateway) {
setSelectedGatewaySnapshot(fallbackGateway);
@@ -1561,6 +1573,16 @@ const load = async () => {
departments.value = unwrap(departmentsResponse, []);
await syncSelection();
} catch (error) {
if (isEdgeGatewayAuthorizationError(error)) {
clearEdgeGatewayWorkspaceCache();
gateways.value = [];
fleetUsage.value = buildFleetUsageFromRows([]);
if (activeGatewayId.value) {
unavailableGatewayId.value = String(activeGatewayId.value);
}
selectedGateway.value = null;
resetGatewayViewSnapshots();
}
fail(error);
} finally {
loading.value.init = false;
+76
View File
@@ -10,6 +10,7 @@ const listRequestsInFlight = new Map();
const detailRequestsInFlight = new Map();
let departmentsRequestInFlight = null;
let edgeGatewayWorkspaceCache = null;
let edgeGatewayWorkspaceCacheStorageKey = null;
const cloneJson = (value) => (value === null || value === undefined ? value : JSON.parse(JSON.stringify(value)));
const normalizeDepartmentId = (departmentId) =>
@@ -132,6 +133,7 @@ const getStoredValue = (key) => {
}
};
<<<<<<< HEAD
const hashScopePart = (value) => {
let hash = 0;
const input = String(value || "");
@@ -212,6 +214,48 @@ const loadWorkspaceCache = () => {
return edgeGatewayWorkspaceCache;
}
=======
const scopedWorkspaceCacheKey = () => {
const localStorageHandle = storage();
if (!localStorageHandle) {
return EDGE_GATEWAY_WORKSPACE_CACHE_KEY;
}
const sessionParts = [
localStorageHandle.getItem("token") || "anonymous",
localStorageHandle.getItem("is_subuser") === "true" ? "subuser" : "primary",
localStorageHandle.getItem("selected_customer_number") || "none",
];
const principal = sessionParts.join("|");
let hash = 0;
for (let index = 0; index < principal.length; index += 1) {
hash = (hash * 31 + principal.charCodeAt(index)) >>> 0;
}
return `${EDGE_GATEWAY_WORKSPACE_CACHE_KEY}.${hash.toString(36)}`;
};
const resetWorkspaceCacheMemory = (storageKey = scopedWorkspaceCacheKey()) => {
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache();
edgeGatewayWorkspaceCacheStorageKey = storageKey;
return edgeGatewayWorkspaceCache;
};
const loadWorkspaceCache = () => {
const storageKey = scopedWorkspaceCacheKey();
if (edgeGatewayWorkspaceCache !== null && edgeGatewayWorkspaceCacheStorageKey === storageKey) {
return edgeGatewayWorkspaceCache;
}
const localStorageHandle = storage();
if (!localStorageHandle) {
return resetWorkspaceCacheMemory(storageKey);
}
try {
localStorageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
const decoded = JSON.parse(localStorageHandle.getItem(storageKey) || "null");
>>>>>>> refs/remotes/origin/master
edgeGatewayWorkspaceCache = {
...createEmptyWorkspaceCache(scope),
cachedAt: Number(decoded.cachedAt || Date.now()),
@@ -225,9 +269,14 @@ const loadWorkspaceCache = () => {
? Object.fromEntries(Object.entries(decoded.details).filter(([, entry]) => isFreshCacheEntry(entry)))
: {},
};
edgeGatewayWorkspaceCacheStorageKey = storageKey;
} catch (_error) {
<<<<<<< HEAD
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache(scope);
storageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
=======
resetWorkspaceCacheMemory(storageKey);
>>>>>>> refs/remotes/origin/master
}
return edgeGatewayWorkspaceCache;
@@ -240,6 +289,7 @@ const persistWorkspaceCache = () => {
return;
}
<<<<<<< HEAD
edgeGatewayWorkspaceCache.cachedAt = Date.now();
storageHandle.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify(edgeGatewayWorkspaceCache));
};
@@ -253,6 +303,11 @@ export const clearEdgeGatewayWorkspaceCache = () => {
}
storageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
=======
const storageKey = edgeGatewayWorkspaceCacheStorageKey || scopedWorkspaceCacheKey();
localStorageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
localStorageHandle.setItem(storageKey, JSON.stringify(edgeGatewayWorkspaceCache));
>>>>>>> refs/remotes/origin/master
};
const mergeGatewaySnapshots = (currentGateway, nextGateway) => {
@@ -412,6 +467,27 @@ const buildOperationPayload = (typeOrPayload, request = {}) => {
export const unwrapEdgeGatewayResponse = (response, fallback = null) => response?.data?.data ?? fallback;
export const unwrapEdgeGatewayMeta = (response) => response?.data?.meta ?? {};
export const isEdgeGatewayAuthorizationError = (error) => {
const status = Number(error?.response?.status || error?.status || error?.response?.data?.status || 0);
return status === 401 || status === 403;
};
export const clearEdgeGatewayWorkspaceCache = () => {
const localStorageHandle = storage();
const storageKey = scopedWorkspaceCacheKey();
resetWorkspaceCacheMemory(storageKey);
if (!localStorageHandle) {
return;
}
Object.keys(localStorageHandle).forEach((key) => {
if (key === EDGE_GATEWAY_WORKSPACE_CACHE_KEY || key.startsWith(`${EDGE_GATEWAY_WORKSPACE_CACHE_KEY}.`)) {
localStorageHandle.removeItem(key);
}
});
localStorageHandle.removeItem(storageKey);
};
export const peekCachedEdgeGatewayDepartments = () => {
const entry = loadWorkspaceCache().departments;
const departments = entry?.data;
+33
View File
@@ -1,5 +1,8 @@
// @vitest-environment jsdom
<<<<<<< HEAD
=======
>>>>>>> refs/remotes/origin/master
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -12,21 +15,31 @@ vi.mock("@/components/session/authenticatedRequest.vue", () => ({
import {
EDGE_GATEWAY_WORKSPACE_CACHE_KEY,
<<<<<<< HEAD
EDGE_GATEWAY_WORKSPACE_CACHE_TTL_MS,
clearEdgeGatewayWorkspaceCache,
=======
clearEdgeGatewayWorkspaceCache,
getEdgeGateway,
>>>>>>> refs/remotes/origin/master
getEdgeGatewayInstallTokenStatus,
listEdgeGateways,
peekCachedEdgeGateway,
peekCachedEdgeGatewayList,
getEdgeGatewayModuleConfig,
peekCachedEdgeGateway,
setEdgeGatewayModuleConfig,
} from "@/services/edgeGateways.js";
describe("edge gateway service", () => {
beforeEach(() => {
authenticatedRequestMock.mockReset();
<<<<<<< HEAD
localStorage.clear();
sessionStorage.clear();
=======
window.localStorage.clear();
>>>>>>> refs/remotes/origin/master
clearEdgeGatewayWorkspaceCache();
});
@@ -86,7 +99,27 @@ describe("edge gateway service", () => {
expect(authenticatedRequestMock).toHaveBeenCalledWith("/edge-gateways/install-token/9001/status", "GET", {});
});
<<<<<<< HEAD
it("uses a stable browser cache namespace for session-scoped workspace snapshots", () => {
=======
it("scopes workspace snapshots to the active session principal", async () => {
window.localStorage.setItem("token", "user-one-token");
authenticatedRequestMock.mockResolvedValueOnce({
data: {
data: { id: 101, label: "User One Gateway" },
},
});
await getEdgeGateway(101);
expect(peekCachedEdgeGateway(101)?.label).toBe("User One Gateway");
window.localStorage.setItem("token", "user-two-token");
expect(peekCachedEdgeGateway(101)).toBeNull();
expect(window.localStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toBeNull();
});
it("keeps a stable browser cache namespace for workspace snapshots", () => {
>>>>>>> refs/remotes/origin/master
const source = readFileSync(join(process.cwd(), "src/services/edgeGateways.js"), "utf8");
expect(EDGE_GATEWAY_WORKSPACE_CACHE_KEY).toBe("truckwash.edgeGatewayWorkspace.cache.v1");
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
normalizeStripeInvoice,
parseStripeInvoicePaidFlag,
} from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
describe("stripeEmailInvoice", () => {
it("parses invoice paid flags without treating non-empty unpaid strings as paid", () => {
expect(parseStripeInvoicePaidFlag(true)).toBe(true);
expect(parseStripeInvoicePaidFlag(1)).toBe(true);
expect(parseStripeInvoicePaidFlag("true")).toBe(true);
expect(parseStripeInvoicePaidFlag("1")).toBe(true);
expect(parseStripeInvoicePaidFlag(false)).toBe(false);
expect(parseStripeInvoicePaidFlag(0)).toBe(false);
expect(parseStripeInvoicePaidFlag("false")).toBe(false);
expect(parseStripeInvoicePaidFlag("0")).toBe(false);
expect(parseStripeInvoicePaidFlag("open")).toBe(false);
});
it("requires paid status, a paid flag, and covered amount before unlocking invoice completion", () => {
expect(
normalizeStripeInvoice({
id: "in_unpaid_string",
paid: "false",
status: "open",
amount_due: "1000",
amount_paid: "0",
})?.paid
).toBe(false);
expect(
normalizeStripeInvoice({
id: "in_open_true_flag",
paid: true,
status: "open",
amount_due: "1000",
amount_paid: "1000",
})?.paid
).toBe(false);
expect(
normalizeStripeInvoice({
id: "in_underpaid",
paid: true,
status: "paid",
amount_due: "1000",
amount_paid: "999",
})?.paid
).toBe(false);
expect(
normalizeStripeInvoice({
id: "in_paid",
paid: "true",
status: "paid",
amount_due: "1000",
amount_paid: "1000",
})?.paid
).toBe(true);
});
});