test: unit-tests der kernlogik und playwright-smoke-test

50 Unit-Tests mit Vitest fuer die Stellen, an denen ein Fehler teuer
waere: Besitzstatus, Mehrfachauswahl inklusive Ueberspringen von
Dubletten und bereits besessenen Sets, Token-Erzeugung und -Vergleich,
Cent-Umrechnung und Slugs.

Der Playwright-Smoke-Test laeuft den ganzen Weg: Registrierung des
ersten Nutzers, Hersteller anlegen, Set anlegen, auf eine neue
Wunschliste setzen, Link holen und als Besucher ohne Konto reservieren.
Er nutzt ein eigenes Datenverzeichnis (./data-e2e), das vor jedem Lauf
geleert wird.

Dabei aufgefallen und behoben: eine spontan aus dem Katalog angelegte
Wunschliste war nicht teilbar, weil das Feld isPublic ohne Formular
auf false fiel. Ausserdem ist das Sternchen an Pflichtfeldern jetzt
aria-hidden, damit der zugaengliche Name des Feldes sauber bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 23:31:21 +02:00
co-authored by Claude Opus 5
parent 79b0133083
commit b0152b8667
16 changed files with 1289 additions and 746 deletions
+1
View File
@@ -19,6 +19,7 @@ next-env.d.ts
# runtime data (database, uploads) - never in git, this is the backup target # runtime data (database, uploads) - never in git, this is the backup target
/data /data
/data-e2e
# production # production
*.tsbuildinfo *.tsbuildinfo
+13
View File
@@ -0,0 +1,13 @@
import { execFileSync } from 'node:child_process';
/**
* Migrates the smoke-test database. The directory itself is wiped earlier by
* scripts/reset-e2e.mjs, because Playwright starts its web server before this
* hook runs and the open database file would block the delete on Windows.
*/
export default function globalSetup() {
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], {
stdio: 'inherit',
env: { ...process.env, DATA_DIR: './data-e2e' },
});
}
+92
View File
@@ -0,0 +1,92 @@
import { expect, test } from '@playwright/test';
/**
* The one path that has to work: register, put a set into the catalog, and
* share it on a wishlist that a visitor can reserve from without an account.
*/
test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ page, browser }) => {
// --- first run: the very first visitor registers and becomes admin --------
await page.goto('/');
await expect(page).toHaveURL(/\/registrieren/);
// Card titles are plain elements, not headings - hence getByText here.
await expect(page.getByText('Erstes Konto anlegen')).toBeVisible();
await page.getByLabel('Name').fill('Florian');
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
await page.getByLabel('Passwort').fill('smoketest-passwort');
await page.getByRole('button', { name: 'Konto anlegen' }).click();
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
// --- master data: a set needs a manufacturer -----------------------------
await page.goto('/verwaltung/hersteller');
await page.getByRole('button', { name: 'Hersteller anlegen' }).click();
await page.getByLabel('Name').fill('Lego');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Lego')).toBeVisible();
// --- catalog: create a set ------------------------------------------------
await page.goto('/katalog/neu');
await page.getByLabel('Setnummer').fill('60398');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Familienhaus und Elektroauto');
await page.getByLabel('Jahr').fill('2023');
await page.getByLabel('UVP').fill('49,99');
await page.getByRole('button', { name: 'Set anlegen' }).click();
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
await expect(page.getByText('Fehlt noch')).toBeVisible();
// --- wishlist: put the set on a fresh list --------------------------------
await page.getByRole('button', { name: 'Auf Wunschliste' }).click();
await page.getByRole('combobox', { name: 'Wunschliste' }).selectOption('__neu__');
await page.getByLabel('Titel der neuen Liste').fill('Weihnachten 2026');
await page.getByRole('button', { name: 'Hinzufügen' }).click();
// Wait for the confirmation, not for the button label that is on screen anyway.
await expect(page.getByText('Auf die Wunschliste gesetzt.')).toBeVisible();
// --- sharing: grab the public link ---------------------------------------
await page.goto('/wunschlisten');
// Navigate by href: a toast can still be on screen and would swallow the click.
const listHref = await page
.getByRole('link', { name: /Weihnachten 2026/ })
.getAttribute('href');
expect(listHref).toMatch(/^\/wunschlisten\//);
await page.goto(listHref!);
await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
const shareUrl = await page.getByLabel('Link zum Teilen').inputValue();
expect(shareUrl).toContain('/w/');
// --- the visitor: no account, no session ---------------------------------
const visitorContext = await browser.newContext();
const visitor = await visitorContext.newPage();
await visitor.goto(shareUrl);
await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
await expect(visitor.getByText('Familienhaus und Elektroauto')).toBeVisible();
await visitor.getByRole('button', { name: 'Das schenke ich' }).click();
await visitor.getByLabel('Dein Name').fill('Tante Anne');
await visitor.getByRole('button', { name: 'Reservieren' }).click();
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
await visitor.getByRole('button', { name: 'Alles klar' }).click();
await expect(visitor.getByText('Das schenkst du')).toBeVisible();
// --- back on the owner side: the reservation shows up ---------------------
await page.reload();
await expect(page.getByText('Reserviert von Tante Anne')).toBeVisible();
await visitorContext.close();
});
test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
await page.goto('/sammlung');
await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
});
test('Ein unbekannter Share-Token liefert 404', async ({ page }) => {
const response = await page.goto('/w/dieser-token-existiert-nicht');
expect(response?.status()).toBe(404);
});
+1 -1
View File
@@ -19,7 +19,7 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "node scripts/reset-e2e.mjs && playwright test",
"screenshots": "playwright test --config=playwright.screenshots.config.ts" "screenshots": "playwright test --config=playwright.screenshots.config.ts"
}, },
"dependencies": { "dependencies": {
+50
View File
@@ -0,0 +1,50 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Smoke test setup.
*
* Runs against its own data directory (./data-e2e) so a test never touches the
* real database. The global setup wipes and migrates it before each run.
*/
const PORT = 3210;
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: false,
workers: 1,
retries: 0,
// Generous: the dev server compiles each route on first visit.
timeout: 240_000,
expect: { timeout: 15_000 },
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
},
projects: [
{
name: 'mobile',
// The app is built for phones first, so the smoke test uses one.
use: { ...devices['Pixel 7'] },
},
],
webServer: {
command: `pnpm exec next dev -p ${PORT}`,
url: `${BASE_URL}/api/health`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
NODE_ENV: 'development',
DATA_DIR: './data-e2e',
BASE_URL,
BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef',
MAX_UPLOAD_MB: '25',
},
},
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Wipes the Playwright data directory before the smoke run.
*
* Runs before Playwright starts, because Playwright boots its web server
* before global setup - on Windows the open SQLite file would then block the
* delete.
*/
import fs from 'node:fs';
import path from 'node:path';
const dataDir = path.resolve(process.cwd(), 'data-e2e');
fs.rmSync(dataDir, { recursive: true, force: true });
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
+6 -1
View File
@@ -97,7 +97,12 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
let wishlistId = targetId; let wishlistId = targetId;
if (wishlistId === NEW_LIST) { if (wishlistId === NEW_LIST) {
const created = await createWishlist({ title: newTitle, occasion: 'other' }); // isPublic: a list created on the fly is meant to be shared right away.
const created = await createWishlist({
title: newTitle,
occasion: 'other',
isPublic: 'on',
});
if (!created.ok) { if (!created.ok) {
toast.error(created.error); toast.error(created.error);
return; return;
+6 -1
View File
@@ -69,7 +69,12 @@ export function SetActions({
let wishlistId = targetId; let wishlistId = targetId;
if (wishlistId === NEW_LIST) { if (wishlistId === NEW_LIST) {
const created = await createWishlist({ title: newTitle, occasion: 'other' }); // isPublic: a list created on the fly is meant to be shared right away.
const created = await createWishlist({
title: newTitle,
occasion: 'other',
isPublic: 'on',
});
if (!created.ok) { if (!created.ok) {
toast.error(created.error); toast.error(created.error);
return; return;
+7 -1
View File
@@ -21,7 +21,13 @@ export function Field({
<div className={cn('space-y-2', className)}> <div className={cn('space-y-2', className)}>
<Label htmlFor={htmlFor}> <Label htmlFor={htmlFor}>
{label} {label}
{required ? <span className="text-destructive"> *</span> : null} {/* aria-hidden keeps the accessible name clean ("Name", not "Name *");
the required state is already carried by the input itself. */}
{required ? (
<span aria-hidden className="text-destructive">
{' *'}
</span>
) : null}
</Label> </Label>
{children} {children}
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null} {hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
+7 -1
View File
@@ -85,7 +85,13 @@ export function ShareCard({
<div className="space-y-2 rounded-lg border border-border p-3"> <div className="space-y-2 rounded-lg border border-border p-3">
<p className="text-sm font-medium">Link zum Teilen</p> <p className="text-sm font-medium">Link zum Teilen</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Input readOnly value={url} onFocus={(event) => event.currentTarget.select()} className="font-mono text-xs" /> <Input
readOnly
aria-label="Link zum Teilen"
value={url}
onFocus={(event) => event.currentTarget.select()}
className="font-mono text-xs"
/>
<Button variant="outline" size="icon" onClick={copy} aria-label="Link kopieren"> <Button variant="outline" size="icon" onClick={copy} aria-label="Link kopieren">
{copied ? <Check className="text-emerald-600" /> : <Copy />} {copied ? <Check className="text-emerald-600" /> : <Copy />}
</Button> </Button>
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { centsToInput, formatEuro, parseEuroInput, sumCents } from '@/lib/money';
describe('Preiseingabe nach Cent', () => {
it('versteht das deutsche Komma', () => {
expect(parseEuroInput('12,99')).toBe(1299);
expect(parseEuroInput('0,05')).toBe(5);
});
it('versteht den englischen Punkt', () => {
expect(parseEuroInput('12.99')).toBe(1299);
});
it('versteht Tausendertrennzeichen in beiden Schreibweisen', () => {
expect(parseEuroInput('1.234,56')).toBe(123456);
expect(parseEuroInput('1,234.56')).toBe(123456);
});
it('ignoriert Euro-Zeichen und Leerzeichen', () => {
expect(parseEuroInput(' 89,99 € ')).toBe(8999);
});
it('liefert null fuer leere Eingaben', () => {
expect(parseEuroInput('')).toBeNull();
expect(parseEuroInput(' ')).toBeNull();
expect(parseEuroInput(null)).toBeNull();
expect(parseEuroInput(undefined)).toBeNull();
});
it('liefert null fuer Unsinn und negative Betraege', () => {
expect(parseEuroInput('abc')).toBeNull();
expect(parseEuroInput('-5')).toBeNull();
});
it('rundet auf ganze Cent', () => {
expect(parseEuroInput('9,999')).toBe(1000);
expect(parseEuroInput('9,994')).toBe(999);
});
});
describe('Preisanzeige', () => {
it('formatiert Cent als Euro-Betrag', () => {
// Intl setzt ein schmales geschuetztes Leerzeichen vor das Zeichen.
expect(formatEuro(1299).replace(/ | /g, ' ')).toBe('12,99 €');
});
it('zeigt fuer unbekannte Preise einen Gedankenstrich', () => {
expect(formatEuro(null)).toBe('–');
expect(formatEuro(undefined)).toBe('–');
});
it('macht aus Cent wieder eine Eingabe ohne Waehrungszeichen', () => {
expect(centsToInput(1299)).toBe('12,99');
expect(centsToInput(null)).toBe('');
});
it('ueberlebt den Rundweg Eingabe -> Cent -> Eingabe', () => {
for (const input of ['0,01', '12,99', '449,99', '1.234,56']) {
const cents = parseEuroInput(input);
expect(cents).not.toBeNull();
expect(parseEuroInput(centsToInput(cents))).toBe(cents);
}
});
});
describe('Summen', () => {
it('addiert exakt, ohne Float-Fehler', () => {
// 0,10 + 0,20 waere als Float 0.30000000000000004.
expect(sumCents([10, 20])).toBe(30);
});
it('ignoriert unbekannte Preise', () => {
expect(sumCents([1299, null, undefined, 100])).toBe(1399);
expect(sumCents([])).toBe(0);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { CollectionStatus } from '@/db/types';
import {
countOwnedCopies,
isOwningStatus,
isSetOwned,
OWNED_STATUSES,
parseOwnershipFilter,
} from '@/lib/ownership';
describe('Besitzstatus', () => {
it('zaehlt jeden Status ausser sold als Besitz', () => {
for (const status of OWNED_STATUSES) {
expect(isOwningStatus(status)).toBe(true);
}
expect(isOwningStatus('sold')).toBe(false);
});
it('gilt ohne Sammlungseintrag als nicht vorhanden', () => {
expect(isSetOwned([])).toBe(false);
});
it('gilt als vorhanden, sobald ein Exemplar nicht verkauft ist', () => {
expect(isSetOwned(['owned'])).toBe(true);
expect(isSetOwned(['built'])).toBe(true);
expect(isSetOwned(['disassembled'])).toBe(true);
expect(isSetOwned(['lent_out'])).toBe(true);
});
it('gilt als fehlend, wenn alle Exemplare verkauft sind', () => {
expect(isSetOwned(['sold'])).toBe(false);
expect(isSetOwned(['sold', 'sold'])).toBe(false);
});
it('gilt als vorhanden, wenn ein Exemplar verkauft und eines geblieben ist', () => {
const statuses: CollectionStatus[] = ['sold', 'built'];
expect(isSetOwned(statuses)).toBe(true);
});
it('zaehlt nur die nicht verkauften Exemplare', () => {
expect(countOwnedCopies(['owned', 'owned', 'sold'])).toBe(2);
expect(countOwnedCopies(['sold'])).toBe(0);
expect(countOwnedCopies([])).toBe(0);
});
});
describe('Besitz-Filter aus der URL', () => {
it('erkennt die vier gueltigen Werte', () => {
expect(parseOwnershipFilter('alle')).toBe('alle');
expect(parseOwnershipFilter('sammlung')).toBe('sammlung');
expect(parseOwnershipFilter('fehlt')).toBe('fehlt');
expect(parseOwnershipFilter('wunschliste')).toBe('wunschliste');
});
it('faellt bei Unsinn auf "alle" zurueck', () => {
expect(parseOwnershipFilter('quatsch')).toBe('alle');
expect(parseOwnershipFilter(undefined)).toBe('alle');
expect(parseOwnershipFilter(null)).toBe('alle');
expect(parseOwnershipFilter('')).toBe('alle');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { slugify, uniqueSlug } from '@/lib/slug';
describe('Slugs', () => {
it('schreibt deutsche Umlaute aus', () => {
expect(slugify('Grüne Kiste')).toBe('gruene-kiste');
expect(slugify('Öl')).toBe('oel');
expect(slugify('Straße')).toBe('strasse');
});
it('entfernt uebrige Akzente', () => {
expect(slugify('Café Déjà vu')).toBe('cafe-deja-vu');
});
it('macht aus Sonderzeichen einzelne Trennstriche', () => {
expect(slugify('Lego City --- Feuerwehr!')).toBe('lego-city-feuerwehr');
});
it('laesst keine Trennstriche am Rand stehen', () => {
expect(slugify(' -Playmobil- ')).toBe('playmobil');
});
it('kuerzt sehr lange Namen', () => {
expect(slugify('a'.repeat(200))).toHaveLength(60);
});
});
describe('Eindeutige Slugs', () => {
it('nimmt den Slug direkt, wenn er frei ist', () => {
expect(uniqueSlug('Lego', [])).toBe('lego');
});
it('haengt einen Zaehler an, bis der Slug frei ist', () => {
expect(uniqueSlug('Lego', ['lego'])).toBe('lego-2');
expect(uniqueSlug('Lego', ['lego', 'lego-2'])).toBe('lego-3');
});
it('faellt bei Namen ohne verwertbare Zeichen auf "eintrag" zurueck', () => {
expect(uniqueSlug('###', [])).toBe('eintrag');
expect(uniqueSlug('###', ['eintrag'])).toBe('eintrag-2');
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { createToken, hashToken, tokensMatch, TOKEN_BYTES } from '@/lib/tokens';
describe('Token-Erzeugung', () => {
it('erzeugt 32 Byte Zufall als base64url', () => {
const token = createToken();
expect(TOKEN_BYTES).toBe(32);
expect(Buffer.from(token, 'base64url')).toHaveLength(32);
// base64url darf nicht in URLs escaped werden muessen.
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('erzeugt bei jedem Aufruf einen anderen Token', () => {
const tokens = new Set(Array.from({ length: 500 }, () => createToken()));
expect(tokens.size).toBe(500);
});
});
describe('Token-Hashing', () => {
it('liefert einen stabilen SHA-256-Hex-Hash', () => {
const hash = hashToken('beispiel');
expect(hash).toHaveLength(64);
expect(hash).toMatch(/^[0-9a-f]+$/);
expect(hashToken('beispiel')).toBe(hash);
});
it('speichert den Klartext nirgends im Hash', () => {
expect(hashToken('geheim')).not.toContain('geheim');
});
it('erkennt den passenden Token', () => {
const token = createToken();
expect(tokensMatch(token, hashToken(token))).toBe(true);
});
it('weist einen falschen Token ab', () => {
expect(tokensMatch(createToken(), hashToken(createToken()))).toBe(false);
});
it('weist einen fehlenden Hash ab, statt zu vertrauen', () => {
const token = createToken();
expect(tokensMatch(token, null)).toBe(false);
expect(tokensMatch(token, undefined)).toBe(false);
expect(tokensMatch(token, '')).toBe(false);
});
it('weist einen Hash falscher Laenge ab, ohne zu werfen', () => {
expect(tokensMatch(createToken(), 'abc')).toBe(false);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { describeBulkAdd, planBulkAdd, priorityLabel } from '@/lib/wishlist-logic';
describe('Mehrfachauswahl auf die Wunschliste', () => {
it('uebernimmt alles, was weder drauf noch in der Sammlung ist', () => {
const plan = planBulkAdd(['a', 'b', 'c'], new Set(), new Set());
expect(plan.toAdd).toEqual(['a', 'b', 'c']);
expect(plan.duplicates).toEqual([]);
expect(plan.owned).toEqual([]);
});
it('ueberspringt Sets, die schon auf der Liste stehen', () => {
const plan = planBulkAdd(['a', 'b', 'c'], new Set(['b']), new Set());
expect(plan.toAdd).toEqual(['a', 'c']);
expect(plan.duplicates).toEqual(['b']);
});
it('ueberspringt Sets, die schon in der Sammlung sind', () => {
const plan = planBulkAdd(['a', 'b'], new Set(), new Set(['a']));
expect(plan.toAdd).toEqual(['b']);
expect(plan.owned).toEqual(['a']);
});
it('zaehlt ein Set, das beides ist, nur als Dublette', () => {
const plan = planBulkAdd(['a'], new Set(['a']), new Set(['a']));
expect(plan.toAdd).toEqual([]);
expect(plan.duplicates).toEqual(['a']);
expect(plan.owned).toEqual([]);
});
it('entfernt Mehrfachnennungen aus der Auswahl', () => {
const plan = planBulkAdd(['a', 'a', 'b', 'a'], new Set(), new Set());
expect(plan.toAdd).toEqual(['a', 'b']);
});
it('behaelt die Reihenfolge der Auswahl bei', () => {
const plan = planBulkAdd(['c', 'a', 'b'], new Set(), new Set());
expect(plan.toAdd).toEqual(['c', 'a', 'b']);
});
it('kommt mit einer leeren Auswahl klar', () => {
const plan = planBulkAdd([], new Set(['a']), new Set(['b']));
expect(plan).toEqual({ toAdd: [], duplicates: [], owned: [] });
});
});
describe('Rueckmeldung nach der Mehrfachauswahl', () => {
const names = new Map([
['a', '60398 Familienhaus'],
['b', '71427 Lloyds Drache'],
['c', '42172 McLaren P1'],
['d', '70392 Novelmore'],
['e', '2-30500 Mustang'],
]);
it('nennt die Zahl der hinzugefuegten Sets im Singular', () => {
const message = describeBulkAdd({ toAdd: ['a'], duplicates: [], owned: [] }, names);
expect(message).toBe('1 Set hinzugefügt');
});
it('benennt uebersprungene Dubletten mit Namen', () => {
const message = describeBulkAdd({ toAdd: ['a'], duplicates: ['b'], owned: [] }, names);
expect(message).toContain('1 übersprungen (schon auf der Liste)');
expect(message).toContain('71427 Lloyds Drache');
});
it('nennt bei vielen Dubletten nur die ersten drei', () => {
const message = describeBulkAdd(
{ toAdd: [], duplicates: ['a', 'b', 'c', 'd', 'e'], owned: [] },
names,
);
expect(message).toContain('und 2 weitere');
expect(message).not.toContain('70392 Novelmore');
});
it('trennt Dubletten von bereits besessenen Sets', () => {
const message = describeBulkAdd({ toAdd: [], duplicates: ['a'], owned: ['b'] }, names);
expect(message).toContain('schon auf der Liste');
expect(message).toContain('schon in der Sammlung');
});
});
describe('Prioritaeten', () => {
it('hat fuer jede Stufe ein deutsches Label', () => {
expect(priorityLabel(1)).toBe('Großer Wunsch');
expect(priorityLabel(2)).toBe('Gerne');
expect(priorityLabel(3)).toBe('Irgendwann');
});
it('faellt bei unbekannten Werten auf die mittlere Stufe zurueck', () => {
expect(priorityLabel(99)).toBe('Gerne');
});
});
+18
View File
@@ -0,0 +1,18 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(root, './src'),
},
},
test: {
environment: 'node',
include: ['tests/unit/**/*.test.ts'],
globals: false,
},
});