Add mass-import feature for customer data including UI, parsing enhancements, and E2E testing

This commit is contained in:
Jeppe Bundgaard
2026-04-23 19:28:13 +02:00
parent 04467316c2
commit 4febb998c3
13 changed files with 737 additions and 77 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+97 -3
View File
@@ -13,6 +13,7 @@ const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `po
.replace(/[^a-zA-Z0-9._-]+/g, "-");
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
const serverOutputLimit = 80;
const activeOutputReaders = [];
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
const viteDevArgs = [
"run",
@@ -289,12 +290,107 @@ async function warmUpAsset(url, expectedContentType, timeoutMs = 120_000) {
throw new Error(`Timed out warming Playwright dev asset ${url}.`);
}
async function fetchWarmedAsset(url, expectedContentType, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
const perRequestTimeoutMs = 10_000;
while (Date.now() < deadline) {
try {
const response = await fetch(url, {
redirect: "manual",
signal: AbortSignal.timeout(perRequestTimeoutMs),
});
const contentType = response.headers.get("content-type") || "";
if (response.ok && contentType.toLowerCase().includes(expectedContentType)) {
return await response.text();
}
} catch {
// keep polling until the Vite transform is ready
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out warming Playwright dev asset ${url}.`);
}
function extractModuleImports(source) {
const specifiers = new Set();
const importPattern =
/\bimport(?:\s+[^"'()]+?\s+from\s+)?["']([^"']+)["']|\bimport\(\s*["']([^"']+)["']\s*\)/g;
for (const match of source.matchAll(importPattern)) {
const specifier = match[1] || match[2];
if (!specifier) {
continue;
}
if (specifier.startsWith("/src/") || specifier.startsWith("/node_modules/.vite/deps/")) {
specifiers.add(specifier);
}
}
return [...specifiers];
}
function resolveExpectedContentType(specifier) {
const cleanSpecifier = specifier.split("?")[0];
if (cleanSpecifier.endsWith(".css")) {
return "css";
}
if (cleanSpecifier.endsWith(".json")) {
return "json";
}
return "javascript";
}
async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}) {
const queue = [{ url: entryUrl, depth: 0 }];
const visited = new Set();
while (queue.length > 0) {
const current = queue.shift();
if (!current || visited.has(current.url)) {
continue;
}
visited.add(current.url);
const source = await fetchWarmedAsset(current.url, "javascript", timeoutMs);
if (current.depth >= depth) {
continue;
}
for (const specifier of extractModuleImports(source)) {
const expectedContentType = resolveExpectedContentType(specifier);
const importUrl = new URL(specifier, current.url).toString();
if (expectedContentType === "javascript" && specifier.startsWith("/src/")) {
queue.push({
url: importUrl,
depth: current.depth + 1,
});
continue;
}
await warmUpAsset(importUrl, expectedContentType, timeoutMs);
}
}
}
async function warmUpDevServer(url) {
const warmupTargets = await buildWarmupTargets();
for (const target of warmupTargets) {
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
}
await warmModuleGraph(new URL("/src/main.js", url).toString());
await new Promise((resolve) => setTimeout(resolve, 1000));
}
export default async function globalSetup() {
@@ -339,6 +435,7 @@ export default async function globalSetup() {
const stderrLines = [];
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
activeOutputReaders.push(stdoutReader, stderrReader);
serverProcess.unref();
await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8");
@@ -349,8 +446,5 @@ export default async function globalSetup() {
} catch (error) {
await killProcessTree(serverProcess.pid);
throw error;
} finally {
stdoutReader.close();
stderrReader.close();
}
}
+12 -7
View File
@@ -1,23 +1,28 @@
<script setup>
import UsersPagination from "@/components/displays/pagination/models/SuperUserDashboard/UsersPagination.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import { showCreateUserForm } from "@/components/forms/superUser/createUserForm.vue";
import CustomersPagination from "@/components/displays/pagination/models/SuperUserDashboard/CustomersPagination.vue";
import { showMassDataInsertModal } from "@/components/forms/other/functions/showModal.vue";
import { init as initCustomerMassImport } from "@/components/forms/other/structures/CustomerMassImportDataStructure.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const showCustomerMassImportModal = () => {
initCustomerMassImport();
showMassDataInsertModal();
};
</script>
<template>
<div>
<PageTitle :title="t('superuser.pages.customers.title')" :subtitle="t('superuser.pages.customers.subtitle')">
<template #buttons>
<!--<button class="button is-dark" @click="showCreateUserForm">
<button class="button is-dark" data-testid="superuser-customers-mass-import-button" @click="showCustomerMassImportModal">
<span class="icon">
<i class="fas fa-plus"></i>
<i class="fas fa-file-import"></i>
</span>
<span>{{ t('superuser.pages.customers.create') }}</span>
</button> -->
<span>Mass Import</span>
</button>
</template>
</PageTitle>
<CustomersPagination auto-load="true" />
@@ -26,4 +31,4 @@ const { t } = useI18n();
<style scoped>
</style>
</style>
@@ -153,7 +153,7 @@ window.addEventListener("keydown", handleKeydown);
</script>
<template>
<div class="modal is-active" ref="modal">
<div class="modal is-active" ref="modal" data-testid="mass-data-inserter-modal">
<div class="modal-background" @click="closeModal"></div>
<div class="modal-card">
<header class="modal-card-head">
@@ -229,4 +229,4 @@ window.addEventListener("keydown", handleKeydown);
<style scoped>
</style>
</style>
@@ -141,7 +141,7 @@ const getProductOptionsLabel = (vehicle) => {
<th v-if="!props.compact">{{ $t('objects.columns.id') }}</th>
<th v-if="!props.compact">{{ $t('objects.columns.customer_id') }}</th>
<th>{{ $t('objects.bookings.columns.reg_1') }}</th>
<th>{{ $t('objects.columns.type') }}</th>
<th>{{ $t('vehicles.type') }}</th>
<th>{{ $t('objects.vehicles.columns.wash_subscription') }}</th>
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
<th v-if="!props.compact">{{ $t('objects.columns.reference') }}</th>
@@ -23,6 +23,7 @@ const onClickSave = () => {
<!-- Confirm, save and close -->
<button
class="button is-light is-fullwidth"
data-testid="mass-data-insert-save-button"
v-bind:disabled="getDataRows().length === 0"
@click="onClickSave"
>
@@ -35,4 +36,4 @@ const onClickSave = () => {
<style scoped>
</style>
</style>
@@ -24,6 +24,7 @@ const onClickClear = () => {
<!-- Add rows -->
<button
class="button is-light"
data-testid="mass-data-insert-add-button"
@click="onClickAdd"
>
<span class="icon is-small">
@@ -34,6 +35,7 @@ const onClickClear = () => {
<!-- Clear rows -->
<button
class="button is-danger is-light"
data-testid="mass-data-insert-clear-button"
@click="onClickClear"
>
<span class="icon is-small">
@@ -46,4 +48,4 @@ const onClickClear = () => {
<style scoped>
</style>
</style>
@@ -1,8 +1,8 @@
<script>
import { ref, computed, watch } from 'vue';
import { ref } from 'vue';
import * as XLSX from "xlsx";
// This is used to make the user select a file (from their computer) to parse locally
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { setDataRows } from "@/components/forms/other/MassDataInserter.vue";
import Swal from "sweetalert2";
// File content
@@ -23,6 +23,10 @@ const showError = (message) => {
});
}
const isSpreadsheetFile = (file) => {
return /\.(xlsx|xls)$/i.test(file?.name ?? '');
};
export const initiateFileSelection = (onParsedFunction = null) => {
// Reset the file content
fileContent.value = '';
@@ -39,7 +43,7 @@ export const initiateFileSelection = (onParsedFunction = null) => {
// Create a file input element
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.csv, .txt'; // Accept only CSV and TXT files
fileInput.accept = '.xlsx, .xls, .csv, .txt, .tsv';
// Listen for the change event
fileInput.addEventListener('change', (event) => {
@@ -47,15 +51,24 @@ export const initiateFileSelection = (onParsedFunction = null) => {
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
if (isSpreadsheetFile(file)) {
if (validateSpreadsheetContent(e.target.result)) {
parseSpreadsheetContent(e.target.result);
}
return;
}
fileContent.value = e.target.result;
// You can now use the file content as needed
//console.log('File content:', fileContent.value);
if (validateFileContent(fileContent.value)) {
// If the file content is valid, parse it
parseFileContent(fileContent.value);
if (validateDelimitedFileContent(fileContent.value)) {
parseDelimitedFileContent(fileContent.value);
}
};
reader.readAsText(file);
if (isSpreadsheetFile(file)) {
reader.readAsArrayBuffer(file);
} else {
reader.readAsText(file);
}
}
});
@@ -67,7 +80,7 @@ export const getFileContent = () => {
return fileContent.value;
};
const validateFileContent = (content) => {
const validateDelimitedFileContent = (content) => {
// Check if the content is empty
if (!content) {
showError('File content is empty');
@@ -75,61 +88,129 @@ const validateFileContent = (content) => {
}
// Check if the content has at least one row
const rows = content.split('\n');
const rows = content
.split('\n')
.map((row) => normalizeLine(row))
.filter((row) => row.trim() !== '');
if (rows.length === 0) {
showError('File has no rows');
return false;
}
// Check if the first row has at least one column
const columns = rows[0].split(',');
if (columns.length === 0) {
showError('File has no columns');
return false;
}
return true;
};
const cleanRow = (row) => {
// Remove leading and trailing whitespace
row = row.trim();
// Remove any extra commas
row = row.replace(/,+/g, ',');
// Remove any trailing commas
row = row.replace(/,$/, '');
// Remove \r and \n characters
row = row.replace(/\r/g, '');
// Replace \t with ;
row = row.replace(/\t/g, ';');
return row;
const validateSpreadsheetContent = (content) => {
if (!(content instanceof ArrayBuffer) || content.byteLength === 0) {
showError('File content is empty');
return false;
}
return true;
};
const parseFileContent = (content) => {
// Split the content by new lines
const rows = content.split('\n');
// Clean up the rows
rows.forEach((row, index) => {
rows[index] = cleanRow(row);
});
// Split each row by commas (or other delimiters)
// This assumes that the file is a CSV file
const parsedData = rows.map(row => row.split(';'));
// Remove empty rows, and columns
parsedData.forEach((row, index) => {
if (row.length === 0 || row[0] === '') {
parsedData.splice(index, 1);
const normalizeLine = (row) => {
return String(row ?? '')
.replace(/\uFEFF/g, '')
.replace(/\r/g, '')
.trimEnd();
};
const detectDelimiter = (rows) => {
const sampleRows = rows
.map((row) => normalizeLine(row))
.filter((row) => row.trim() !== '')
.slice(0, 5);
const candidates = ['\t', ';', ','];
let selectedDelimiter = ';';
let highestScore = -1;
candidates.forEach((candidate) => {
const score = sampleRows.reduce((sum, row) => {
return sum + ((row.match(new RegExp(candidate === '\t' ? '\\t' : `\\${candidate}`, 'g')) || []).length);
}, 0);
if (score > highestScore) {
highestScore = score;
selectedDelimiter = candidate;
}
row.forEach((column, index) => {
if (column === '') {
row.splice(index, 1);
}
});
});
// Set the parsed data to the dataRows
console.log(parsedData);
return selectedDelimiter;
};
const parseDelimitedRow = (row, delimiter) => {
const values = [];
let currentValue = '';
let insideQuotes = false;
for (let index = 0; index < row.length; index += 1) {
const character = row[index];
const nextCharacter = row[index + 1];
if (character === '"') {
if (insideQuotes && nextCharacter === '"') {
currentValue += '"';
index += 1;
continue;
}
insideQuotes = !insideQuotes;
continue;
}
if (!insideQuotes && character === delimiter) {
values.push(currentValue.trim());
currentValue = '';
continue;
}
currentValue += character;
}
values.push(currentValue.trim());
return values;
};
const parseDelimitedFileContent = (content) => {
const rows = content
.split('\n')
.map((row) => normalizeLine(row))
.filter((row) => row.trim() !== '');
const delimiter = detectDelimiter(rows);
const parsedData = rows
.map((row) => parseDelimitedRow(row, delimiter))
.filter((row) => row.some((column) => column !== ''));
parsedFileContent.value = parsedData;
onParsed(onParsedFunctionCache.value);
};
const parseSpreadsheetContent = (content) => {
const workbook = XLSX.read(content, {
type: 'array',
raw: true,
});
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) {
showError('File has no sheets');
return;
}
const worksheet = workbook.Sheets[firstSheetName];
const parsedData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
blankrows: false,
defval: '',
raw: true,
})
.map((row) => (Array.isArray(row) ? row.map((column) => String(column ?? '').trim()) : []))
.filter((row) => row.some((column) => column !== ''));
parsedFileContent.value = parsedData;
// Run the onParsed function
onParsed(onParsedFunctionCache.value);
};
@@ -143,4 +224,4 @@ const onParsed = (onParsedFunction) => {
showError('No onParsedFunction set');
}
};
</script>
</script>
@@ -0,0 +1,219 @@
<script>
import {
clearProgress,
getCompleted,
getFailed,
getPending,
getProcessing,
getRequestLimit,
getResponses,
setCompleted,
setDataColumns,
setDataRows,
setFailed,
setParsedDataFormatFunction,
setPending,
setProcessing,
setRequestLimit,
setResponses,
setSaveFunction,
setSubtitle,
setTitle,
} from "@/components/forms/other/MassDataInserter.vue";
import { generateNewColumn } from "@/components/forms/other/functions/manageDataColumns.vue";
import { columnTypes } from "@/components/forms/other/types/dataColumnsType.vue";
import { applyColumnProperties } from "@/components/forms/other/types/dataColumn.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
const HEADER_ALIASES = {
cvr: ["cvr", "vat", "vatnumber", "corporateidentificationnumber"],
name: ["navn", "name", "company", "companyname", "firmanavn"],
email: ["email", "mail", "e-mail", "invoiceemail", "contactemail"],
ean: ["ean"],
phone: ["telefon", "telefonnummer", "telefonnummer", "phone", "phonenumber", "customernumber", "kundenummer"],
};
const normalizeHeader = (value) => {
return String(value ?? "")
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]/g, "");
};
const normalizeText = (value) => {
const normalized = String(value ?? "").trim();
return normalized !== "" ? normalized : null;
};
const normalizeDigits = (value) => {
const digits = String(value ?? "").replace(/\D+/g, "").trim();
return digits !== "" ? digits : null;
};
const resolveHeaderField = (value) => {
const normalized = normalizeHeader(value);
return Object.entries(HEADER_ALIASES).find(([, aliases]) => aliases.includes(normalized))?.[0] || null;
};
const detectHeaderMap = (row) => {
if (!Array.isArray(row)) {
return null;
}
const map = {};
row.forEach((value, index) => {
const field = resolveHeaderField(value);
if (field && map[field] === undefined) {
map[field] = index;
}
});
const matchedFields = Object.keys(map);
if (matchedFields.length < 2) {
return null;
}
if (map.cvr === undefined && map.phone === undefined) {
return null;
}
return map;
};
const extractRowValue = (row, field, headerMap) => {
if (Array.isArray(row)) {
if (headerMap && headerMap[field] !== undefined) {
return row[headerMap[field]] ?? null;
}
const fallbackIndexByField = {
cvr: 0,
name: 1,
email: 2,
ean: 3,
phone: 4,
};
const fallbackIndex = fallbackIndexByField[field];
return fallbackIndex !== undefined ? row[fallbackIndex] ?? null : null;
}
if (row && typeof row === "object") {
const aliases = HEADER_ALIASES[field] || [];
const keys = [field, ...aliases];
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(row, key)) {
return row[key];
}
}
}
return null;
};
const parseRows = (rows) => {
const safeRows = Array.isArray(rows) ? rows : [];
const headerMap = detectHeaderMap(safeRows[0]);
const sourceRows = headerMap ? safeRows.slice(1) : safeRows;
const parsedRows = [];
sourceRows.forEach((row, index) => {
const parsedRow = {
cvr: normalizeDigits(extractRowValue(row, "cvr", headerMap)),
name: normalizeText(extractRowValue(row, "name", headerMap)),
email: normalizeText(extractRowValue(row, "email", headerMap)),
ean: normalizeDigits(extractRowValue(row, "ean", headerMap)),
phone: normalizeDigits(extractRowValue(row, "phone", headerMap)),
};
parsedRow.customer_number = parsedRow.phone;
if (!parsedRow.cvr && !parsedRow.name && !parsedRow.email && !parsedRow.ean && !parsedRow.phone) {
return;
}
parsedRow.id = `customer-import-${index + 1}-${parsedRow.phone || parsedRow.cvr || index + 1}`;
parsedRows.push(parsedRow);
});
setDataRows(parsedRows);
return parsedRows;
};
const processPendingCustomers = () => {
while (getPending().length > 0 && getProcessing().length < getRequestLimit()) {
const object = getPending()[0];
setPending(getPending().slice(1));
setProcessing([...getProcessing(), object]);
processCustomer(object);
}
};
const processCustomer = (object) => {
authenticatedRequest("/customers/import", "POST", {
customer_number: object.customer_number,
phone: object.phone,
cvr: object.cvr,
name: object.name,
email: object.email,
ean: object.ean,
}).then((response) => {
setResponses([
...getResponses(),
{
object,
success: true,
data: response.data,
},
]);
setCompleted([...getCompleted(), object]);
setProcessing(getProcessing().filter((processingObject) => processingObject !== object));
}).catch((error) => {
setResponses([
...getResponses(),
{
object,
success: false,
error,
},
]);
setFailed([...getFailed(), object]);
setProcessing(getProcessing().filter((processingObject) => processingObject !== object));
}).finally(() => {
processPendingCustomers();
});
};
export const init = () => {
clearProgress();
setDataRows([]);
setTitle("Mass Import Customers");
setSubtitle("Import or create company customers from spreadsheet rows");
setRequestLimit(3);
setDataColumns([
generateNewColumn("cvr", columnTypes.string, applyColumnProperties({ required: true, label: "CVR" })),
generateNewColumn("name", columnTypes.string, applyColumnProperties({ required: false, label: "Name" })),
generateNewColumn("email", columnTypes.string, applyColumnProperties({ required: false, label: "Email" })),
generateNewColumn("ean", columnTypes.string, applyColumnProperties({ required: false, label: "EAN" })),
generateNewColumn("phone", columnTypes.string, applyColumnProperties({ required: true, label: "Phone" })),
]);
setParsedDataFormatFunction((objects) => parseRows(objects));
setSaveFunction((objects) => {
clearProgress();
setResponses([]);
setPending([...objects]);
setProcessing([]);
setCompleted([]);
setFailed([]);
processPendingCustomers();
});
};
</script>
@@ -34,7 +34,7 @@ export const Vehicles = {
}
},
type: {
label: t('objects.columns.type'),
label: t('vehicles.type'),
type: "select",
sortable: true,
creation: {
@@ -248,4 +248,4 @@ export const Vehicles = {
);
},
};
</script>
</script>
+6 -6
View File
@@ -40,7 +40,7 @@
"role": "IT chef"
},
"mads": {
"role": "VD"
"role": "Direktør"
},
"mikkel": {
"role1": "Grundlægger",
@@ -631,7 +631,7 @@
"status_barred": "Spærret",
"status_booking": "Booking",
"status_unknown": "Ny",
"type": "Typ",
"type": "Type",
"unknown": "Ugendt",
"use": "Brug",
"variant": "Variant",
@@ -2415,7 +2415,7 @@
"task": "Opgave",
"title": "Titel",
"total_net_amount": "Total netto beløb",
"type": "Typ",
"type": "Type",
"updated_at": "Opdateret",
"url": "URL",
"user_id": "Bruger ID",
@@ -4143,7 +4143,7 @@
"self_service": "Selvvask",
"self_service_placeholder": "Placeholder for køretøjer med selvbetjening",
"subscriptions_placeholder": "Placeholder for køretøjer med aktive vaskeabonnementer",
"type_label": "Typ",
"type_label": "Type",
"vehicle_count_placeholder": "Placeholder for køretøjsantal",
"vehicle_type": "Vogntype",
"vehicles_with_self_service": "Køretøjer med selvbetjening",
@@ -4185,7 +4185,7 @@
"customer": "Kunde",
"license_plate": "Registreringsnummer",
"submit": "Lagre",
"type": "Typ"
"type": "Type"
},
"history": "Historik",
"license_plate": "Registreringsnummer",
@@ -4204,7 +4204,7 @@
"singular": "Kjøretøy",
"subtitle": "Administrer kjøretøy",
"title": "Køretøjer",
"type": "Typ",
"type": "Type",
"types": {
"bus": "Bus",
"car": "Bil",
@@ -0,0 +1,233 @@
import { Buffer } from "node:buffer";
import { expect, test, type Page } from "@playwright/test";
import * as XLSX from "xlsx";
import { seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
const json = (body: unknown, status = 200) => ({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
const buildWorkbookBuffer = () => {
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.aoa_to_sheet([
["CVR", "Navn", "Email", "EAN", "Telefon nummer"],
["31744520", "SPF-DANMARK A/S", "spf@example.com", "5790000000001", "76964600"],
["26761751", "STEA A/S", "", "", "75773355"],
]);
XLSX.utils.book_append_sheet(workbook, worksheet, "Customers");
return Buffer.from(XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }));
};
async function gotoCustomersImportPage(page: Page) {
const importButton = page.getByTestId("superuser-customers-mass-import-button");
for (let attempt = 1; attempt <= 3; attempt += 1) {
await page.goto("/superuser/customers", { waitUntil: "domcontentloaded" });
if ((await importButton.count()) > 0) {
return importButton;
}
await page.waitForTimeout(1_000 * attempt);
}
return importButton;
}
test.describe("Superuser customers mass import", () => {
test("parses spreadsheet rows with blank middle columns and submits them row-by-row", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
const importPayloads: Array<Record<string, unknown>> = [];
await page.route(/https?:\/\/(?:api\.truckwash\.io(?::\d+)?)\/.*/i, async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
const method = request.method();
if (pathname.endsWith("/auth/session") && method === "GET") {
await route.fulfill(
json({
data: {
id: 1,
customer_number: 12345,
group_id: 1,
email: "superuser@example.com",
phone: {
number: "12345678",
country_code: 45,
},
notifications: {
wash_certificate_email: null,
email_notifications_enabled: true,
sms_notifications_enabled: false,
},
display_name: "E2E Superuser",
permissions: ["superuser", "search_customers", "add_user", "user"],
economic_customer: [],
runtime_config: {
economic: {
transaction_draft_customer_number: null,
},
},
},
})
);
return;
}
if (
(pathname.endsWith("/auth/recaptcha/pre-check") || pathname.endsWith("/auth/reCAPTCHA/public")) &&
method === "GET"
) {
await route.fulfill(
json({
data: {
recaptcha: {
enabled: false,
site_key: "",
},
rate_limit: {
enabled: false,
limit: 0,
remaining: 0,
reset: 0,
warning: null,
},
},
})
);
return;
}
if (pathname.endsWith("/ping") && method === "GET") {
await route.fulfill(json({ data: { ok: true } }));
return;
}
if (pathname.endsWith("/worker/version") && method === "GET") {
await route.fulfill(json({ data: { version: "test-build" } }));
return;
}
if (pathname.endsWith("/departments") && method === "GET") {
await route.fulfill(json({ data: [{ id: 1, name: "Hvidovre", visible: true }] }));
return;
}
if (pathname.endsWith("/customers") && method === "GET") {
await route.fulfill(
json({
data: [
{
id: 88,
customerNumber: 44556677,
name: "Existing Customer",
email: "existing@example.com",
balance: 0,
currency: "DKK",
barred: false,
},
],
meta: {
pagination: {
page: 1,
per_page: 100,
total: 1,
},
},
})
);
return;
}
if (pathname.endsWith("/customers/import") && method === "POST") {
const body = request.postDataJSON() as Record<string, unknown>;
importPayloads.push(body);
if (String(body.customer_number) === "75773355") {
await route.fulfill(
json(
{
data: {
message: "Customer already exists locally and already has a login account.",
},
},
409
)
);
return;
}
await route.fulfill(
json({
data: {
action: "created_customer",
message: "Created the customer in e-conomic and imported it locally.",
customer_number: Number(body.customer_number),
has_account: false,
},
})
);
return;
}
await route.fulfill(json({ data: [] }));
});
await seedAuthenticatedState(page, "superuser-customers-import-token");
const importButton = await gotoCustomersImportPage(page);
await expect(importButton).toBeVisible();
await importButton.click();
await expect(page.getByTestId("mass-data-inserter-modal")).toBeVisible();
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByTestId("mass-data-insert-add-button").click(),
]);
await fileChooser.setFiles({
name: "customers.xlsx",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
buffer: buildWorkbookBuffer(),
});
const modal = page.getByTestId("mass-data-inserter-modal");
await expect(modal).toContainText("SPF-DANMARK A/S");
await expect(modal).toContainText("76964600");
await expect(modal).toContainText("STEA A/S");
await expect(modal).toContainText("75773355");
await page.getByTestId("mass-data-insert-save-button").click();
await expect.poll(() => importPayloads.length).toBe(2);
expect(importPayloads[0]).toMatchObject({
cvr: "31744520",
name: "SPF-DANMARK A/S",
email: "spf@example.com",
ean: "5790000000001",
phone: "76964600",
customer_number: "76964600",
});
expect(importPayloads[1]).toMatchObject({
cvr: "26761751",
name: "STEA A/S",
email: null,
ean: null,
phone: "75773355",
customer_number: "75773355",
});
await expect(modal.locator(".tag.is-success")).toHaveCount(1);
await expect(modal.locator(".tag.is-danger")).toHaveCount(1);
});
});
+26 -1
View File
@@ -1,7 +1,30 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { loginAsUser } from "./fixtures";
const json = (body, status = 200) => ({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
const suppressVersionCheck = async (page) => {
await page.addInitScript(() => {
window.localStorage.setItem("lastVersionCheck", String(Date.now()));
});
await page.route(/\/worker\/version(\?.*)?$/i, async (route) => {
await route.fulfill(
json({
data: {
version: "test-build",
},
})
);
});
};
test("[PAGES][User][/user/vehicles] shows the current vehicles overview shell", async ({ page }) => {
await suppressVersionCheck(page);
await loginAsUser(page);
await page.goto("/user/vehicles");
@@ -10,6 +33,7 @@ test("[PAGES][User][/user/vehicles] shows the current vehicles overview shell",
});
test("[PAGES][User][/user/vehicles] opens the add-vehicle dialog", async ({ page }) => {
await suppressVersionCheck(page);
await loginAsUser(page);
await page.goto("/user/vehicles");
@@ -17,6 +41,7 @@ test("[PAGES][User][/user/vehicles] opens the add-vehicle dialog", async ({ page
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("heading", { name: /opret/i })).toBeVisible();
await expect(dialog.locator("label").filter({ hasText: /^Type$/ })).toBeVisible();
await expect(dialog.getByRole("button", { name: /gem/i })).toBeVisible();
await expect(dialog.getByRole("button", { name: /annuller/i })).toBeVisible();
});