Fix subuser QR session bootstrap
This commit is contained in:
@@ -64,6 +64,7 @@ import {
|
||||
clearPeriodCache,
|
||||
clearSelfWashCountsCache,
|
||||
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
|
||||
import { markStoredSessionAsSubuser } from "@/services/sessionStorage.js";
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
@@ -403,6 +404,12 @@ export const isInvalidSessionError = (error) => {
|
||||
);
|
||||
};
|
||||
|
||||
const isUserEndpointSubuserTokenMismatch = (error) => {
|
||||
const status = Number.parseInt(String(error?.response?.status ?? ""), 10);
|
||||
const message = extractErrorMessage(error).toLowerCase();
|
||||
return [400, 401].includes(status) && message.includes("user not found");
|
||||
};
|
||||
|
||||
const redirectToConnectivityIssue = () => {
|
||||
if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") {
|
||||
return false;
|
||||
@@ -412,9 +419,10 @@ const redirectToConnectivityIssue = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleSessionBootstrapFailure = async (error, { title, text } = {}) => {
|
||||
export const handleSessionBootstrapFailure = async (error, { title, text, subuserSession = false } = {}) => {
|
||||
if (isInvalidSessionError(error)) {
|
||||
const wasSubuserSession = SessionUser.isSubuser.value || getStoredSessionSnapshot()?.isSubuser === true;
|
||||
const wasSubuserSession =
|
||||
Boolean(subuserSession) || SessionUser.isSubuser.value || getStoredSessionSnapshot()?.isSubuser === true;
|
||||
const pingResult = await pingApiServer();
|
||||
if (!pingResult.ok) {
|
||||
console.warn("Preserving stored session because API ping failed during invalid-session handling.", pingResult);
|
||||
@@ -573,6 +581,44 @@ export const authenticateSubuser = async (credentials) => {
|
||||
});
|
||||
};
|
||||
|
||||
const applySubuserSessionPayload = async (payload) => {
|
||||
const data = payload || {};
|
||||
SessionUser.isSubuser.value = true;
|
||||
markStoredSessionAsSubuser();
|
||||
SessionUser.subuser.id.value = data.id;
|
||||
SessionUser.subuser.username.value = data.username;
|
||||
SessionUser.subuser.name.value = data.name;
|
||||
SessionUser.subuser.email.value = data.email;
|
||||
SessionUser.subuser.email_verified.value = Boolean(data.email_verified);
|
||||
SessionUser.subuser.email_verified_at.value = data.email_verified_at || null;
|
||||
SessionUser.subuser.phone.country_code.value = data.phone_country_code;
|
||||
SessionUser.subuser.phone.number.value = data.phone;
|
||||
SessionUser.subuser.phone.verified.value = Boolean(data.phone_verified);
|
||||
SessionUser.subuser.phone.verified_at.value = data.phone_verified_at || null;
|
||||
SessionUser.subuser.verification_state.value = data.verification_state || data.verification?.state || null;
|
||||
SessionUser.subuser.verification.value = data.verification || null;
|
||||
SessionUser.subuser.grants.value = data.grants || [];
|
||||
reconcileSelectedSubuserGrant(SessionUser.subuser.grants.value);
|
||||
SessionUser.subuser.created_at.value = data.created_at;
|
||||
SessionUser.subuser.updated_at.value = data.updated_at;
|
||||
SessionUser.subuser.suspended_at.value = data.suspended_at;
|
||||
SessionUser.subuser.cached_at.value = new Date();
|
||||
setReleaseChannelSwitchNoticePrincipal(`subuser:${data.id || data.username || data.email || "unknown"}`);
|
||||
|
||||
// Build permissions from grants
|
||||
const allPermissions = [];
|
||||
if (data.grants && Array.isArray(data.grants)) {
|
||||
data.grants.forEach((grant) => {
|
||||
if (grant.permissions && Array.isArray(grant.permissions)) {
|
||||
allPermissions.push(...grant.permissions);
|
||||
}
|
||||
});
|
||||
}
|
||||
SessionUser.permissions.value = [...new Set(allPermissions)];
|
||||
await refreshReleaseRuntime();
|
||||
SessionUser.initiated.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the subuser's session data from /subusers/me
|
||||
* @returns {Promise<void>}
|
||||
@@ -581,38 +627,7 @@ export const getSubuserSessionData = async () => {
|
||||
return await authenticatedRequest("/subusers/me", "GET")
|
||||
.then(async (response) => {
|
||||
const data = response.data.data || response.data;
|
||||
SessionUser.subuser.id.value = data.id;
|
||||
SessionUser.subuser.username.value = data.username;
|
||||
SessionUser.subuser.name.value = data.name;
|
||||
SessionUser.subuser.email.value = data.email;
|
||||
SessionUser.subuser.email_verified.value = Boolean(data.email_verified);
|
||||
SessionUser.subuser.email_verified_at.value = data.email_verified_at || null;
|
||||
SessionUser.subuser.phone.country_code.value = data.phone_country_code;
|
||||
SessionUser.subuser.phone.number.value = data.phone;
|
||||
SessionUser.subuser.phone.verified.value = Boolean(data.phone_verified);
|
||||
SessionUser.subuser.phone.verified_at.value = data.phone_verified_at || null;
|
||||
SessionUser.subuser.verification_state.value = data.verification_state || data.verification?.state || null;
|
||||
SessionUser.subuser.verification.value = data.verification || null;
|
||||
SessionUser.subuser.grants.value = data.grants || [];
|
||||
reconcileSelectedSubuserGrant(SessionUser.subuser.grants.value);
|
||||
SessionUser.subuser.created_at.value = data.created_at;
|
||||
SessionUser.subuser.updated_at.value = data.updated_at;
|
||||
SessionUser.subuser.suspended_at.value = data.suspended_at;
|
||||
SessionUser.subuser.cached_at.value = new Date();
|
||||
setReleaseChannelSwitchNoticePrincipal(`subuser:${data.id || data.username || data.email || "unknown"}`);
|
||||
|
||||
// Build permissions from grants
|
||||
const allPermissions = [];
|
||||
if (data.grants && Array.isArray(data.grants)) {
|
||||
data.grants.forEach((grant) => {
|
||||
if (grant.permissions && Array.isArray(grant.permissions)) {
|
||||
allPermissions.push(...grant.permissions);
|
||||
}
|
||||
});
|
||||
}
|
||||
SessionUser.permissions.value = [...new Set(allPermissions)];
|
||||
await refreshReleaseRuntime();
|
||||
SessionUser.initiated.value = true;
|
||||
await applySubuserSessionPayload(data);
|
||||
})
|
||||
.catch(async (error) => {
|
||||
if (isReleaseChannelApiAvailabilityError(error)) {
|
||||
@@ -624,6 +639,7 @@ export const getSubuserSessionData = async () => {
|
||||
return await handleSessionBootstrapFailure(error, {
|
||||
title: "Fejl ved hentning af subbrugerdata",
|
||||
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
|
||||
subuserSession: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -692,6 +708,10 @@ export const getSessionData = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUserEndpointSubuserTokenMismatch(error)) {
|
||||
return await getSubuserSessionData();
|
||||
}
|
||||
|
||||
return await handleSessionBootstrapFailure(error, {
|
||||
title: "Fejl ved hentning af brugerdata",
|
||||
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
|
||||
|
||||
@@ -1,16 +1,87 @@
|
||||
export const getStoredSessionToken = () => {
|
||||
const browserLocalStorage = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem('token');
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeStoredCustomerNumber = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim();
|
||||
if (!/^[1-9][0-9]*$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(normalized, 10);
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export const getStoredSessionToken = () => {
|
||||
return browserLocalStorage()?.getItem('token') ?? null;
|
||||
};
|
||||
|
||||
export const hasStoredSessionToken = () => {
|
||||
const token = getStoredSessionToken();
|
||||
return typeof token === 'string' && token.length > 0;
|
||||
};
|
||||
|
||||
export const clearStoredSubuserSessionContext = () => {
|
||||
const storage = browserLocalStorage();
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
storage.removeItem('is_subuser');
|
||||
storage.removeItem('selected_customer_number');
|
||||
return true;
|
||||
};
|
||||
|
||||
export const markStoredSessionAsSubuser = ({ selectedCustomerNumber } = {}) => {
|
||||
const storage = browserLocalStorage();
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
storage.setItem('is_subuser', 'true');
|
||||
|
||||
if (selectedCustomerNumber !== undefined) {
|
||||
const normalizedCustomerNumber = normalizeStoredCustomerNumber(selectedCustomerNumber);
|
||||
if (normalizedCustomerNumber === null) {
|
||||
storage.removeItem('selected_customer_number');
|
||||
} else {
|
||||
storage.setItem('selected_customer_number', normalizedCustomerNumber.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const storeSessionToken = (token, { isSubuser = false, selectedCustomerNumber = null } = {}) => {
|
||||
const storage = browserLocalStorage();
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedToken = typeof token === 'string' ? token.trim() : String(token ?? '').trim();
|
||||
if (normalizedToken.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
storage.setItem('token', normalizedToken);
|
||||
|
||||
if (isSubuser) {
|
||||
markStoredSessionAsSubuser({ selectedCustomerNumber });
|
||||
} else {
|
||||
clearStoredSubuserSessionContext();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -13,16 +13,31 @@ import { sounds } from "@/components/displays/department/pos/steps/mobile/object
|
||||
import PageLoader from "@/components/global/PageLoader.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
import { storeSessionToken } from "@/services/sessionStorage.js";
|
||||
|
||||
const isProcessingQRCode = ref(false); // State to indicate if QR code is being processed
|
||||
const playCaptureSound = () => {
|
||||
sounds.play(sounds.list.value.onAfterSuccessfulScan)
|
||||
};
|
||||
|
||||
const applyToken = (token: string) => {
|
||||
const isSubuserSessionType = (type: string | null) => String(type || "").trim().toLowerCase() === "subuser";
|
||||
|
||||
const applyToken = (
|
||||
token: string,
|
||||
{
|
||||
type = null,
|
||||
customerNumber = null,
|
||||
}: {
|
||||
type?: string | null;
|
||||
customerNumber?: string | null;
|
||||
} = {}
|
||||
) => {
|
||||
isProcessingQRCode.value = true; // Start processing
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem("token", token);
|
||||
storeSessionToken(token, {
|
||||
isSubuser: isSubuserSessionType(type),
|
||||
selectedCustomerNumber: customerNumber,
|
||||
});
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
@@ -33,7 +48,10 @@ const applyTokenIfPresent = () => {
|
||||
if (token) {
|
||||
// If a token is present, store it in localStorage
|
||||
console.log("Token found in URL:", token);
|
||||
applyToken(token); // Apply the token (store and redirect)
|
||||
applyToken(token, {
|
||||
type: urlParams.get("type"),
|
||||
customerNumber: urlParams.get("customer_number"),
|
||||
}); // Apply the token (store and redirect)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +110,10 @@ function onDetect(detectedCodes) {
|
||||
const url = new URL(validCode.rawValue);
|
||||
const token = url.searchParams.get("token");
|
||||
if (token) {
|
||||
applyToken(token);
|
||||
applyToken(token, {
|
||||
type: url.searchParams.get("type"),
|
||||
customerNumber: url.searchParams.get("customer_number"),
|
||||
});
|
||||
} else {
|
||||
console.warn("No token found in the URL:", validCode.rawValue);
|
||||
}
|
||||
|
||||
@@ -43,4 +43,64 @@ test.describe("Auth entry smoke", () => {
|
||||
expect(bootstrapErrors).not.toContain("/node_modules/qrcode/lib/browser.js");
|
||||
expect(bootstrapErrors).not.toContain("at useQRCode.mjs");
|
||||
});
|
||||
|
||||
test("@smoke subuser qr login bootstraps through /subusers/me instead of /auth/session", async ({ page }) => {
|
||||
const authSessionRequests = [];
|
||||
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "GET" && request.url().includes("/auth/session")) {
|
||||
authSessionRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await mockApi(page, { authenticated: false });
|
||||
await page.route("**/release/runtime**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: {} }),
|
||||
});
|
||||
});
|
||||
await page.route("**/subusers/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
id: 77,
|
||||
username: "driver-77",
|
||||
name: "Driver 77",
|
||||
email: "driver@example.test",
|
||||
email_verified: true,
|
||||
email_verified_at: "2026-07-13 10:00:00",
|
||||
phone_country_code: 45,
|
||||
phone: 12345678,
|
||||
phone_verified: true,
|
||||
phone_verified_at: "2026-07-13 10:00:00",
|
||||
verification_state: "verified",
|
||||
verification: { state: "verified" },
|
||||
grants: [
|
||||
{
|
||||
grant_id: 5,
|
||||
billing_customer_number: 12345678,
|
||||
permissions: ["BOOKINGS_LIST"],
|
||||
},
|
||||
],
|
||||
created_at: "2026-07-13 09:00:00",
|
||||
updated_at: "2026-07-13 09:30:00",
|
||||
suspended_at: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login/qr?token=subuser-qr-token&type=subuser&customer_number=12345678", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("is_subuser"))).toBe("true");
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("selected_customer_number"))).toBe("12345678");
|
||||
await expect(page).toHaveURL(/\/user$/, { timeout: AUTH_ENTRY_TIMEOUT });
|
||||
expect(authSessionRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
authenticatedRequest: vi.fn(),
|
||||
parseError: vi.fn(),
|
||||
pingApiServer: vi.fn(),
|
||||
fetchReleaseRuntime: vi.fn(() => Promise.resolve({})),
|
||||
swalFire: vi.fn(() => Promise.resolve({ isConfirmed: true })),
|
||||
loginRedirect: vi.fn(),
|
||||
subuserLoginRedirect: vi.fn(),
|
||||
@@ -23,6 +24,10 @@ vi.mock("@/services/apiHealth.js", () => ({
|
||||
pingApiServer: mocks.pingApiServer,
|
||||
}));
|
||||
|
||||
vi.mock("@/services/releaseBootstrap.js", () => ({
|
||||
fetchReleaseRuntime: mocks.fetchReleaseRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("sweetalert2", () => ({
|
||||
default: {
|
||||
fire: mocks.swalFire,
|
||||
@@ -58,6 +63,46 @@ const invalidSessionError = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
const userNotFoundSessionError = () => ({
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
data: {
|
||||
message: "User not found",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const subuserSessionPayload = () => ({
|
||||
data: {
|
||||
data: {
|
||||
id: 77,
|
||||
username: "driver-77",
|
||||
name: "Driver 77",
|
||||
email: "driver@example.test",
|
||||
email_verified: true,
|
||||
email_verified_at: "2026-07-13 10:00:00",
|
||||
phone_country_code: 45,
|
||||
phone: 12345678,
|
||||
phone_verified: true,
|
||||
phone_verified_at: "2026-07-13 10:00:00",
|
||||
verification_state: "verified",
|
||||
verification: { state: "verified" },
|
||||
grants: [
|
||||
{
|
||||
grant_id: 5,
|
||||
billing_customer_number: 12345678,
|
||||
permissions: ["BOOKINGS_LIST", "VEHICLES_LIST"],
|
||||
},
|
||||
],
|
||||
created_at: "2026-07-13 09:00:00",
|
||||
updated_at: "2026-07-13 09:30:00",
|
||||
suspended_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("session bootstrap failure handling", () => {
|
||||
beforeEach(() => {
|
||||
SessionUser.auth.forceClearSession();
|
||||
@@ -66,6 +111,8 @@ describe("session bootstrap failure handling", () => {
|
||||
mocks.authenticatedRequest.mockReset();
|
||||
mocks.parseError.mockReset();
|
||||
mocks.pingApiServer.mockReset();
|
||||
mocks.fetchReleaseRuntime.mockReset();
|
||||
mocks.fetchReleaseRuntime.mockResolvedValue({});
|
||||
mocks.swalFire.mockClear();
|
||||
mocks.loginRedirect.mockReset();
|
||||
mocks.subuserLoginRedirect.mockReset();
|
||||
@@ -246,6 +293,54 @@ describe("session bootstrap failure handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers a stored subuser token that was missing the subuser localStorage marker", async () => {
|
||||
seedStoredSession("subuser-token-without-marker");
|
||||
mocks.authenticatedRequest.mockRejectedValueOnce(userNotFoundSessionError());
|
||||
mocks.authenticatedRequest.mockResolvedValueOnce(subuserSessionPayload());
|
||||
|
||||
const result = await getSessionData();
|
||||
|
||||
expect(mocks.authenticatedRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.authenticatedRequest.mock.calls[0][0]).toBe("/auth/session");
|
||||
expect(mocks.authenticatedRequest.mock.calls[1][0]).toBe("/subusers/me");
|
||||
expect(result).toBeUndefined();
|
||||
expect(localStorage.getItem("token")).toBe("subuser-token-without-marker");
|
||||
expect(localStorage.getItem("is_subuser")).toBe("true");
|
||||
expect(localStorage.getItem("selected_customer_number")).toBe("12345678");
|
||||
expect(SessionUser.isSubuser.value).toBe(true);
|
||||
expect(SessionUser.initiated.value).toBe(true);
|
||||
expect(SessionUser.subuser.id.value).toBe(77);
|
||||
expect(SessionUser.permissions.value).toEqual(["BOOKINGS_LIST", "VEHICLES_LIST"]);
|
||||
expect(mocks.loginRedirect).not.toHaveBeenCalled();
|
||||
expect(mocks.subuserLoginRedirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to driver login when subuser-token recovery also proves invalid", async () => {
|
||||
seedStoredSession("stale-subuser-token-without-marker");
|
||||
mocks.authenticatedRequest.mockRejectedValueOnce(userNotFoundSessionError());
|
||||
mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError());
|
||||
mocks.pingApiServer.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "/api/ping",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await getSessionData();
|
||||
|
||||
expect(mocks.authenticatedRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.authenticatedRequest.mock.calls[1][0]).toBe("/subusers/me");
|
||||
expect(localStorage.getItem("token")).toBeNull();
|
||||
expect(localStorage.getItem("is_subuser")).toBeNull();
|
||||
expect(mocks.loginRedirect).not.toHaveBeenCalled();
|
||||
expect(mocks.subuserLoginRedirect).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
cleared: true,
|
||||
apiReachable: true,
|
||||
redirectedTo: "/login/driver",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an invalid stored session and routes to connectivity when ping fails", async () => {
|
||||
seedStoredSession("invalid-token");
|
||||
mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError());
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeStoredCustomerNumber, storeSessionToken } from "@/services/sessionStorage.js";
|
||||
|
||||
describe("session storage helpers", () => {
|
||||
it("stores subuser QR session context with the selected customer number", () => {
|
||||
const stored = storeSessionToken("subuser-qr-token", {
|
||||
isSubuser: true,
|
||||
selectedCustomerNumber: "12345678",
|
||||
});
|
||||
|
||||
expect(stored).toBe(true);
|
||||
expect(localStorage.getItem("token")).toBe("subuser-qr-token");
|
||||
expect(localStorage.getItem("is_subuser")).toBe("true");
|
||||
expect(localStorage.getItem("selected_customer_number")).toBe("12345678");
|
||||
});
|
||||
|
||||
it("clears stale subuser context for normal QR session tokens", () => {
|
||||
localStorage.setItem("is_subuser", "true");
|
||||
localStorage.setItem("selected_customer_number", "12345678");
|
||||
|
||||
const stored = storeSessionToken("customer-qr-token");
|
||||
|
||||
expect(stored).toBe(true);
|
||||
expect(localStorage.getItem("token")).toBe("customer-qr-token");
|
||||
expect(localStorage.getItem("is_subuser")).toBeNull();
|
||||
expect(localStorage.getItem("selected_customer_number")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-positive or mixed customer number values", () => {
|
||||
expect(normalizeStoredCustomerNumber("12345678")).toBe(12345678);
|
||||
expect(normalizeStoredCustomerNumber("0")).toBeNull();
|
||||
expect(normalizeStoredCustomerNumber("123abc")).toBeNull();
|
||||
expect(normalizeStoredCustomerNumber("")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user