Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
398045b519 | ||
|
|
acbbac588f | ||
|
|
bfb1386d49 | ||
|
|
f75ff97042 | ||
|
|
e495a099e4 | ||
|
|
c4e29a896a | ||
|
|
9f7ebad5fe | ||
|
|
98d0fbc8c9 | ||
|
|
fef1fc3c12 |
+20
-13
@@ -49,13 +49,19 @@ concurrency:
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Repository variables used as CI runner and credit controls:
|
||||
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
|
||||
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
|
||||
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
|
||||
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
|
||||
# GitHub-hosted example: ["ubuntu-22.04"], with PR parallelism 2 and full parallelism 1.
|
||||
jobs:
|
||||
format-tests:
|
||||
# CI normally runs on the repository's self-hosted pool; manual runs can opt into GitHub-hosted runners.
|
||||
runs-on: ${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.runner == 'github-hosted' && '["ubuntu-latest"]' || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Normalize workspace permissions
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
@@ -88,10 +94,11 @@ jobs:
|
||||
|
||||
build-and-unit:
|
||||
needs: format-tests
|
||||
runs-on: ${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.runner == 'github-hosted' && '["ubuntu-latest"]' || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Normalize workspace permissions
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
@@ -314,12 +321,11 @@ jobs:
|
||||
)
|
||||
needs: [build-and-unit, e2e-targeted]
|
||||
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
# Use GitHub-hosted runners to avoid self-hosted desktop contention and sleep/power events.
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
||||
matrix:
|
||||
suite: [core, changed]
|
||||
project: [chromium-desktop, chromium-mobile]
|
||||
@@ -329,7 +335,8 @@ jobs:
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_VIDEO_MODE: on-first-retry
|
||||
steps:
|
||||
- name: Normalize workspace permissions
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
@@ -515,12 +522,11 @@ jobs:
|
||||
)
|
||||
needs: [build-and-unit, e2e-pr, e2e-targeted]
|
||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
runs-on: ${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.runner == 'github-hosted' && '["ubuntu-latest"]' || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Manual GitHub-hosted runs can use paid runner capacity to finish the full matrix faster.
|
||||
max-parallel: ${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.runner == 'github-hosted' && '12' || '2') }}
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
|
||||
matrix:
|
||||
browser: [chromium, webkit, firefox]
|
||||
device: [mobile, desktop, tablet]
|
||||
@@ -541,7 +547,8 @@ jobs:
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_VIDEO_MODE: off
|
||||
steps:
|
||||
- name: Normalize workspace permissions
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<script setup>
|
||||
|
||||
const props = defineProps(['invoice_id'])
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { handleEconomicError } from "@/components/request/HandleEconomicError.vue";
|
||||
import { ref } from "vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps(["invoice_id"]);
|
||||
|
||||
const hasInvoice = computed(() => {
|
||||
return props.invoice_id !== null && props.invoice_id !== undefined && String(props.invoice_id).trim() !== "";
|
||||
});
|
||||
|
||||
const getOrderPDF = async () => {
|
||||
await authenticatedRequest('/invoices/pdf?id=' + props.invoice_id,
|
||||
'GET',{
|
||||
}) .then((response) => {
|
||||
if (!hasInvoice.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await authenticatedRequest("/invoices/pdf", "GET", {
|
||||
id: props.invoice_id,
|
||||
}).then((response) => {
|
||||
// Open the PDF in a new tab
|
||||
window.open(response.data.data.url, '_blank');
|
||||
window.open(response.data.data.url, "_blank");
|
||||
}).catch((error) => {
|
||||
handleEconomicError(error);
|
||||
});
|
||||
@@ -19,9 +27,17 @@ const getOrderPDF = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadButtonWhileAwait class="is-dark" :loadFunction="getOrderPDF" icon="fas fa-file-pdf"> Hent faktura PDF </LoadButtonWhileAwait>
|
||||
<LoadButtonWhileAwait
|
||||
class="is-dark"
|
||||
:loadFunction="getOrderPDF"
|
||||
:disabled="!hasInvoice"
|
||||
actionKey="economic-invoice-pdf-download"
|
||||
icon="fas fa-file-pdf"
|
||||
>
|
||||
Hent faktura PDF
|
||||
</LoadButtonWhileAwait>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
|
||||
import {ref, onMounted, nextTick, computed, onUnmounted} from "vue";
|
||||
import {ref, onMounted, nextTick, computed, onUnmounted, watch} from "vue";
|
||||
import { IS_DEV } from '@/config.js';
|
||||
import {BLoading} from "buefy";
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
/**
|
||||
* Subuser grant selection gate
|
||||
@@ -76,6 +77,59 @@ const canShowVehicleShortcut = computed(() => SessionUser.canAccessCustomerFeatu
|
||||
const canShowOrderShortcut = computed(() => SessionUser.canAccessCustomerFeature("orders", "list"));
|
||||
const canShowSelfServeShortcut = computed(() => SessionUser.canAccessCustomerFeature("selfserve", "list"));
|
||||
const canShowInvoiceShortcut = computed(() => canShowClassicCustomerShortcut());
|
||||
const hasInvoicesAvailable = ref(false);
|
||||
const isCheckingInvoiceAvailability = ref(true);
|
||||
const canDownloadInvoices = computed(() => canShowInvoiceShortcut.value && hasInvoicesAvailable.value && !isCheckingInvoiceAvailability.value);
|
||||
|
||||
const responseHasInvoices = (response: any) => {
|
||||
const total = Number(response?.data?.meta?.pagination?.total);
|
||||
if (Number.isFinite(total) && total > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.isArray(response?.data?.data) && response.data.data.length > 0;
|
||||
};
|
||||
|
||||
const resetInvoiceAvailability = () => {
|
||||
hasInvoicesAvailable.value = false;
|
||||
isCheckingInvoiceAvailability.value = false;
|
||||
};
|
||||
|
||||
const loadInvoiceAvailability = async () => {
|
||||
if (!canShowInvoiceShortcut.value) {
|
||||
resetInvoiceAvailability();
|
||||
return;
|
||||
}
|
||||
|
||||
isCheckingInvoiceAvailability.value = true;
|
||||
|
||||
try {
|
||||
const response = await authenticatedRequest("/user/invoices", "GET", {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
order: "created_at:desc",
|
||||
});
|
||||
hasInvoicesAvailable.value = responseHasInvoices(response);
|
||||
} catch (_error) {
|
||||
hasInvoicesAvailable.value = false;
|
||||
} finally {
|
||||
isCheckingInvoiceAvailability.value = false;
|
||||
setEqualHeights();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
canShowInvoiceShortcut,
|
||||
(canShow) => {
|
||||
if (canShow) {
|
||||
void loadInvoiceAvailability();
|
||||
return;
|
||||
}
|
||||
|
||||
resetInvoiceAvailability();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const windowInnerWidth = ref(window.innerWidth);
|
||||
const updateWindowInnerWidth = () => {
|
||||
@@ -291,9 +345,26 @@ const isPermissionsLoading = computed(() => {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="card-footer-item">
|
||||
<router-link to="/user/invoices" class="button is-link is-fullwidth" :class="classes.button" id="download-invoices-button">
|
||||
<router-link
|
||||
v-if="canDownloadInvoices"
|
||||
to="/user/invoices"
|
||||
class="button is-link is-fullwidth"
|
||||
:class="classes.button"
|
||||
id="download-invoices-button"
|
||||
>
|
||||
{{ $t('user_home.download_invoices') }}
|
||||
</router-link>
|
||||
<button
|
||||
v-else
|
||||
class="button is-link is-fullwidth"
|
||||
:class="[classes.button, { 'is-loading': isCheckingInvoiceAvailability }]"
|
||||
id="download-invoices-button"
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
>
|
||||
{{ $t('user_home.download_invoices') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
|
||||
@@ -370,8 +370,12 @@ const isInvoiced = () => {
|
||||
return isInvoicedWithEconomic() || isInvoicedWithStripe();
|
||||
};
|
||||
|
||||
const isBookedWithEconomic = () => {
|
||||
return economicModule.value.invoice_id !== null;
|
||||
const hasEconomicInvoiceDownloadContext = () => {
|
||||
const module = economicModule.value || {};
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(module, 'invoice_id') ||
|
||||
Object.prototype.hasOwnProperty.call(module, 'invoice_draft_id')
|
||||
);
|
||||
};
|
||||
|
||||
const isCompleted = () => {
|
||||
@@ -1376,7 +1380,11 @@ const isDisplayingReceipt = () => {
|
||||
|
||||
<template #rail-actions>
|
||||
<ButtonsBox class="pos-actions pos-actions--rail">
|
||||
<GetOrderInvoicePDFButton :invoice_id="economicModule.invoice_id" class="is-fullwidth" v-if="isBookedWithEconomic()" />
|
||||
<GetOrderInvoicePDFButton
|
||||
:invoice_id="economicModule.invoice_id"
|
||||
class="is-fullwidth"
|
||||
v-if="hasEconomicInvoiceDownloadContext()"
|
||||
/>
|
||||
<div
|
||||
v-if="isEconomicExportBlocked"
|
||||
class="message is-warning is-light"
|
||||
|
||||
@@ -1844,7 +1844,7 @@ test("shows live Stripe payment status and cancellation for open card payment or
|
||||
await expect(page.getByTestId("pos-order-stripe-email-cancel")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-print-receipt")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Fuldfør ordre|Complete order/ })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Hent faktura|Get invoice/ })).toHaveCount(0);
|
||||
await expect(page.locator('[data-action-key="economic-invoice-pdf-download"]')).toBeDisabled();
|
||||
|
||||
await page.getByTestId("pos-order-stripe-email-cancel").click();
|
||||
await deleteStripeInvoiceRequest;
|
||||
@@ -2950,6 +2950,28 @@ test.describe("Admin POS Orders - desktop locked states", () => {
|
||||
await expectOrderTotal(page, 1372);
|
||||
});
|
||||
|
||||
test("disables invoice PDF download while the Economic invoice id is missing", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: createPosFixture({
|
||||
economicModuleOrdersByOrderId: {
|
||||
54518: {
|
||||
invoice_id: null,
|
||||
invoice_draft_id: 88012,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await primeOperatorSession(page, "pos-orders-draft-invoice-token");
|
||||
await openOrderDetail(page);
|
||||
|
||||
const downloadButton = page.locator('[data-action-key="economic-invoice-pdf-download"]');
|
||||
await expect(downloadButton).toBeVisible();
|
||||
await expect(downloadButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("hides item mutation controls when the order is already invoiced", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
@@ -2970,6 +2992,7 @@ test.describe("Admin POS Orders - desktop locked states", () => {
|
||||
await expect(page.getByTestId("pos-order-item-edit-9101")).toHaveCount(0);
|
||||
await expect(page.getByTestId("pos-order-item-delete-9101")).toHaveCount(0);
|
||||
await expect(page.getByTestId("pos-order-add-item")).toHaveCount(0);
|
||||
await expect(page.locator('[data-action-key="economic-invoice-pdf-download"]')).toBeEnabled();
|
||||
await expectOrderTotal(page, 1372);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -350,6 +350,10 @@ async function readStoredToken(page: Page) {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAppRootMounted(page: Page) {
|
||||
await expect.poll(async () => page.locator("#app > *").count(), { timeout: AUTH_TIMEOUT }).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
async function settleAuthenticatedNavigation(page: Page, targetPath: string, targetUrl: RegExp) {
|
||||
await expect.poll(() => readStoredToken(page), { timeout: AUTH_TIMEOUT }).not.toBeNull();
|
||||
const navigateToTarget = async () => {
|
||||
@@ -376,6 +380,8 @@ async function settleAuthenticatedNavigation(page: Page, targetPath: string, tar
|
||||
await navigateToTarget();
|
||||
await expect.poll(() => page.url(), { timeout: AUTH_TIMEOUT }).toMatch(targetUrl);
|
||||
}
|
||||
|
||||
await waitForAppRootMounted(page);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,10 @@ const GENERATED_LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "gen
|
||||
const REVIEWED_NON_LITERAL_CALLS = new Set([
|
||||
"src/views/backoffice/LimitedBackofficeEmployees.vue|t|return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;",
|
||||
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue|t|const translated = t(key, params);",
|
||||
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
|
||||
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
|
||||
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
|
||||
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
|
||||
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|return t(`replication.status.${status || "unknown"}`);',
|
||||
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|te|if (key && te(key)) {",
|
||||
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|return t(key, params);",
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 98 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 107 KiB |
@@ -2328,7 +2328,11 @@ test("superusers manage release settings, assignments, integrations, and sync op
|
||||
await expect(page.getByTestId("release-assignment-subject-manual-user-909")).toContainText("User #909");
|
||||
await expect(page.getByTestId("release-assignment-subject-manual-subuser-909")).toContainText("Subuser #909");
|
||||
await expect(page.getByTestId("release-assignment-subject-manual-customer-909")).toContainText("Customer #909");
|
||||
await page.getByTestId("release-assignment-subject-manual-subuser-909").click({ force: true });
|
||||
await page
|
||||
.getByTestId("release-assignment-subject-autocomplete")
|
||||
.getByRole("button", { name: /Subuser #909/ })
|
||||
.click();
|
||||
await expect(assignmentSubjectInput).toHaveValue("Subuser #909 - Use typed value subuser:909");
|
||||
await page.getByTestId("release-assignment-form").getByRole("button", { name: "Assign" }).click();
|
||||
expect(state.assignmentPayloads[state.assignmentPayloads.length - 1]).toMatchObject({
|
||||
subject_type: "subuser",
|
||||
|
||||
@@ -27,10 +27,7 @@ async function primeSession(page, { token, permissions, sessionData = {} }) {
|
||||
await seedAuthenticatedState(page, token);
|
||||
}
|
||||
|
||||
async function mockSelectedSubuserGrant(
|
||||
page,
|
||||
{ customerNumber = 12345679, permissions = ["user", "SELFSERVE_LIST", "SELFSERVE_ADD"] } = {}
|
||||
) {
|
||||
async function mockSelectedSubuserGrant(page, { customerNumber = 12345679, permissions = ["SELFSERVE_LIST"] } = {}) {
|
||||
await page.route("**/subusers/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -6740,6 +6740,51 @@ export async function mockApi(page, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/user/invoices") && method === "GET") {
|
||||
const invoiceFixture = posFixture || { collectedInvoices: [] };
|
||||
const customerNumber = Number(options.sessionData?.customer_number || 0);
|
||||
const collectedInvoices = (invoiceFixture.collectedInvoices || []).filter((invoice) => {
|
||||
if (!customerNumber) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Number(invoice.customer_number) === customerNumber;
|
||||
});
|
||||
const page = Number(parsedUrl.searchParams.get("page") || 1);
|
||||
const limit = Number(parsedUrl.searchParams.get("limit") || collectedInvoices.length || 100);
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: collectedInvoices.slice((page - 1) * limit, page * limit),
|
||||
meta: {
|
||||
pagination: {
|
||||
page,
|
||||
per_page: limit,
|
||||
total: collectedInvoices.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/invoices/pdf") && method === "GET") {
|
||||
const invoiceId = String(parsedUrl.searchParams.get("id") || "").trim();
|
||||
if (!invoiceId) {
|
||||
await route.fulfill(json({ message: "Invoice not found" }, 404));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
url: `https://pdf.example.test/invoices/${invoiceId}.pdf`,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/collected-invoices") && method === "GET") {
|
||||
const invoiceFixture = posFixture || { collectedInvoices: [] };
|
||||
const filters = parseFilterExpressions(parsedUrl.searchParams.get("filters") || "");
|
||||
|
||||
+48
-22
@@ -1,20 +1,25 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
userCredentials,
|
||||
subuserPhoneCredentials,
|
||||
subuserUsernameCredentials,
|
||||
operatorCredentials,
|
||||
customerRegistrationData,
|
||||
driverRegistrationData,
|
||||
invalidCredentials,
|
||||
loginAsUser,
|
||||
loginAsSubuserByPhone,
|
||||
loginAsSubuserByUsername,
|
||||
loginAsOperator,
|
||||
goToUserLogin,
|
||||
goToSubuserLogin,
|
||||
goToOperatorLogin,
|
||||
} from "./fixtures";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
import { loginAsUser } from "./fixtures";
|
||||
|
||||
const USER_HOME_CUSTOMER_NUMBER = 12345679;
|
||||
|
||||
async function gotoUserHomeWithInvoices(page: Page, collectedInvoices: Array<Record<string, unknown>>) {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
sessionData: {
|
||||
customer_number: USER_HOME_CUSTOMER_NUMBER,
|
||||
display_name: "E2E User",
|
||||
permissions: ["user"],
|
||||
},
|
||||
pos: createPosFixture({
|
||||
collectedInvoices,
|
||||
}),
|
||||
});
|
||||
await seedAuthenticatedState(page, "user-home-invoice-shortcut-token");
|
||||
await page.goto("/user", { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL(/\/user(?:\/)?(?:[?#].*)?$/);
|
||||
}
|
||||
|
||||
/** User home Tests */
|
||||
// User home page content tests
|
||||
@@ -41,9 +46,30 @@ test("[PAGES][User][/user] should display the download certificate button", asyn
|
||||
// Check if the "download certificate" element is visible
|
||||
await expect(page.locator("a#download-certificates-button")).toBeVisible();
|
||||
});
|
||||
// Download invoices button visibility test
|
||||
test("[PAGES][User][/user] should display the download invoices button", async ({ page }) => {
|
||||
await loginAsUser(page);
|
||||
// Check if the "download invoices" element is visible
|
||||
await expect(page.locator("a#download-invoices-button")).toBeVisible();
|
||||
test("[PAGES][User][/user] should disable the download invoices button when no invoice exists", async ({ page }) => {
|
||||
await gotoUserHomeWithInvoices(page, []);
|
||||
|
||||
const button = page.locator("button#download-invoices-button");
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toBeDisabled();
|
||||
await expect(button).not.toHaveClass(/is-loading/);
|
||||
await expect(page.locator("a#download-invoices-button")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("[PAGES][User][/user] should enable the download invoices button when an invoice exists", async ({ page }) => {
|
||||
await gotoUserHomeWithInvoices(page, [
|
||||
{
|
||||
id: 7001,
|
||||
customer_number: USER_HOME_CUSTOMER_NUMBER,
|
||||
customer_name: "E2E User",
|
||||
total_net_amount: 1250,
|
||||
created_at: "2026-07-01",
|
||||
closed_at: "2026-07-01",
|
||||
},
|
||||
]);
|
||||
|
||||
const link = page.locator("a#download-invoices-button");
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute("href", "/user/invoices");
|
||||
await expect(page.locator("button#download-invoices-button")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticatedRequest: vi.fn(),
|
||||
handleEconomicError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
||||
authenticatedRequest: mocks.authenticatedRequest,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/request/HandleEconomicError.vue", () => ({
|
||||
handleEconomicError: mocks.handleEconomicError,
|
||||
}));
|
||||
|
||||
import GetOrderInvoicePDFButton from "@/components/search/economic/getOrderInvoicePDFButton.vue";
|
||||
|
||||
const LoadButtonWhileAwaitStub = {
|
||||
props: ["disabled", "loadFunction", "actionKey"],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:data-action-key="actionKey"
|
||||
@click="loadFunction"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
`,
|
||||
};
|
||||
|
||||
describe("GetOrderInvoicePDFButton", () => {
|
||||
beforeEach(() => {
|
||||
mocks.authenticatedRequest.mockReset();
|
||||
mocks.handleEconomicError.mockReset();
|
||||
vi.stubGlobal("open", vi.fn());
|
||||
});
|
||||
|
||||
it.each([null, undefined, ""])("disables the PDF download when invoice id is %s", async (invoiceId) => {
|
||||
const wrapper = mount(GetOrderInvoicePDFButton, {
|
||||
props: {
|
||||
invoice_id: invoiceId,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
LoadButtonWhileAwait: LoadButtonWhileAwaitStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const button = wrapper.get('[data-action-key="economic-invoice-pdf-download"]');
|
||||
expect(button.attributes("disabled")).toBeDefined();
|
||||
|
||||
await button.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.authenticatedRequest).not.toHaveBeenCalled();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requests and opens the invoice PDF when invoice id exists", async () => {
|
||||
mocks.authenticatedRequest.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
url: "https://pdf.example.test/invoices/99101.pdf",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(GetOrderInvoicePDFButton, {
|
||||
props: {
|
||||
invoice_id: 99101,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
LoadButtonWhileAwait: LoadButtonWhileAwaitStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const button = wrapper.get('[data-action-key="economic-invoice-pdf-download"]');
|
||||
expect(button.attributes("disabled")).toBeUndefined();
|
||||
|
||||
await button.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.authenticatedRequest).toHaveBeenCalledWith("/invoices/pdf", "GET", {
|
||||
id: 99101,
|
||||
});
|
||||
expect(window.open).toHaveBeenCalledWith("https://pdf.example.test/invoices/99101.pdf", "_blank");
|
||||
});
|
||||
});
|
||||
@@ -33,9 +33,12 @@ describe("Playwright full E2E workflow grouping", () => {
|
||||
it("keeps self-hosted full-suite runner pressure bounded while hosted dispatch can fan out", () => {
|
||||
const source = workflowSource();
|
||||
|
||||
expect(source).toMatch(
|
||||
/e2e-full:[\s\S]*?max-parallel: \$\{\{ fromJSON\(github\.event_name == 'workflow_dispatch' && github\.event\.inputs\.runner == 'github-hosted' && '12' \|\| '2'\) \}\}/u
|
||||
);
|
||||
expect(source).toContain("FRONTEND_CI_STANDARD_RUNNER");
|
||||
expect(source).toContain("FRONTEND_CI_E2E_RUNNER");
|
||||
expect(source).toContain("FRONTEND_CI_PR_E2E_MAX_PARALLEL");
|
||||
expect(source).toContain("FRONTEND_CI_FULL_E2E_MAX_PARALLEL");
|
||||
expect(source).toMatch(/e2e-pr:[\s\S]*?max-parallel: \$\{\{ fromJSON\(vars\.FRONTEND_CI_PR_E2E_MAX_PARALLEL/u);
|
||||
expect(source).toMatch(/e2e-full:[\s\S]*?max-parallel: \$\{\{ fromJSON\(vars\.FRONTEND_CI_FULL_E2E_MAX_PARALLEL/u);
|
||||
expect(source).toMatch(/e2e-full:[\s\S]*?PLAYWRIGHT_WORKERS: 1/u);
|
||||
expect(source).toMatch(/e2e-full:[\s\S]*?PLAYWRIGHT_VIDEO_MODE: off/u);
|
||||
expect(source).toContain("scripts/ci/with-systemd-inhibit.sh");
|
||||
@@ -55,22 +58,24 @@ describe("Playwright full E2E workflow grouping", () => {
|
||||
it("keeps PR E2E runner pressure bounded and diagnosable", () => {
|
||||
const source = workflowSource();
|
||||
|
||||
expect(source).toMatch(/e2e-pr:[\s\S]*?runs-on: ubuntu-24\.04/u);
|
||||
expect(source).toMatch(/e2e-pr:[\s\S]*?max-parallel: 4/u);
|
||||
expect(source).toContain("FRONTEND_CI_E2E_RUNNER");
|
||||
expect(source).toMatch(
|
||||
/e2e-pr:[\s\S]*?max-parallel: \$\{\{ fromJSON\(vars\.FRONTEND_CI_PR_E2E_MAX_PARALLEL \|\| '2'\) \}\}/u
|
||||
);
|
||||
expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_WORKERS: 1/u);
|
||||
expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_VIDEO_MODE: on-first-retry/u);
|
||||
expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_WORKERS="\$PLAYWRIGHT_WORKERS"/u);
|
||||
expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_VIDEO_MODE="\$PLAYWRIGHT_VIDEO_MODE"/u);
|
||||
});
|
||||
|
||||
it("supports GitHub-hosted manual runners while keeping PR E2E on Ubuntu", () => {
|
||||
it("supports repo-variable runner controls while keeping targeted reruns on Ubuntu", () => {
|
||||
const source = workflowSource();
|
||||
|
||||
expect(source).toContain("github.event.inputs.runner == 'github-hosted'");
|
||||
expect(source).toContain('["ubuntu-latest"]');
|
||||
expect(source).toContain("FRONTEND_CI_STANDARD_RUNNER");
|
||||
expect(source).toContain("FRONTEND_CI_E2E_RUNNER");
|
||||
expect(source).toContain('["self-hosted","Linux","X64","pleno","frontend"]');
|
||||
expect(source).toMatch(/e2e-targeted:[\s\S]*?runs-on: ubuntu-24\.04/u);
|
||||
expect(source).toMatch(/e2e-pr:[\s\S]*?runs-on: ubuntu-24\.04/u);
|
||||
expect(source).toMatch(/e2e-pr:[\s\S]*?runs-on: \$\{\{ fromJSON\(vars\.FRONTEND_CI_E2E_RUNNER/u);
|
||||
expect(source).toMatch(/e2e-full:[\s\S]*?frontend","docker"\]/u);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushPromises } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const device = {
|
||||
isMobile: vi.fn(() => false),
|
||||
isTablet: vi.fn(() => false),
|
||||
isDesktop: vi.fn(() => true),
|
||||
isWidescreen: vi.fn(() => false),
|
||||
isUltraWideScreen: vi.fn(() => false),
|
||||
};
|
||||
|
||||
return {
|
||||
authenticatedRequest: vi.fn(),
|
||||
sessionUser: {
|
||||
isSubuser: {
|
||||
value: false,
|
||||
},
|
||||
subuser: {
|
||||
selectedGrantCustomerNumber: {
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
value: ["user"],
|
||||
},
|
||||
hasPermission: vi.fn((permission) => permission === "user"),
|
||||
canAccessCustomerFeature: vi.fn(() => true),
|
||||
canManageSubusers: vi.fn(() => false),
|
||||
getName: vi.fn(() => "E2E User"),
|
||||
functions: {
|
||||
device,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
||||
authenticatedRequest: mocks.authenticatedRequest,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
default: mocks.sessionUser,
|
||||
}));
|
||||
|
||||
import UserDashboard from "@/views/dashboards/UserDashboard.vue";
|
||||
|
||||
const flushDashboard = async () => {
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
const RouterLinkStub = {
|
||||
props: ["to"],
|
||||
template: "<a v-bind=\"$attrs\" :href=\"typeof to === 'string' ? to : '#'\"><slot /></a>",
|
||||
};
|
||||
|
||||
const mountDashboard = () =>
|
||||
mountWithApp(UserDashboard, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
"router-link": RouterLinkStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("UserDashboard invoice shortcut", () => {
|
||||
beforeEach(() => {
|
||||
mocks.authenticatedRequest.mockReset();
|
||||
mocks.sessionUser.isSubuser.value = false;
|
||||
mocks.sessionUser.permissions.value = ["user"];
|
||||
mocks.sessionUser.hasPermission.mockReset();
|
||||
mocks.sessionUser.hasPermission.mockImplementation((permission) => permission === "user");
|
||||
mocks.sessionUser.canAccessCustomerFeature.mockReset();
|
||||
mocks.sessionUser.canAccessCustomerFeature.mockReturnValue(true);
|
||||
mocks.sessionUser.canManageSubusers.mockReset();
|
||||
mocks.sessionUser.canManageSubusers.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("keeps the invoice shortcut disabled while invoice availability is loading", () => {
|
||||
mocks.authenticatedRequest.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const wrapper = mountDashboard();
|
||||
|
||||
const button = wrapper.get("button#download-invoices-button");
|
||||
expect(button.attributes("disabled")).toBeDefined();
|
||||
expect(button.classes()).toContain("is-loading");
|
||||
expect(wrapper.find("a#download-invoices-button").exists()).toBe(false);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("keeps the invoice shortcut disabled when no invoice exists", async () => {
|
||||
mocks.authenticatedRequest.mockResolvedValue({
|
||||
data: {
|
||||
data: [],
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountDashboard();
|
||||
await flushDashboard();
|
||||
|
||||
const button = wrapper.get("button#download-invoices-button");
|
||||
expect(button.attributes("disabled")).toBeDefined();
|
||||
expect(button.classes()).not.toContain("is-loading");
|
||||
expect(wrapper.find("a#download-invoices-button").exists()).toBe(false);
|
||||
expect(mocks.authenticatedRequest).toHaveBeenCalledWith("/user/invoices", "GET", {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
order: "created_at:desc",
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("enables the invoice shortcut when at least one invoice exists", async () => {
|
||||
mocks.authenticatedRequest.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 501 }],
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountDashboard();
|
||||
await flushDashboard();
|
||||
|
||||
const link = wrapper.get("a#download-invoices-button");
|
||||
expect(link.attributes("href")).toBe("/user/invoices");
|
||||
expect(wrapper.find("button#download-invoices-button").exists()).toBe(false);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("fails closed when invoice availability cannot be loaded", async () => {
|
||||
mocks.authenticatedRequest.mockRejectedValue(new Error("Network unavailable"));
|
||||
|
||||
const wrapper = mountDashboard();
|
||||
await flushDashboard();
|
||||
|
||||
const button = wrapper.get("button#download-invoices-button");
|
||||
expect(button.attributes("disabled")).toBeDefined();
|
||||
expect(wrapper.find("a#download-invoices-button").exists()).toBe(false);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user