-
{message}
+ return (
+
+
-
- );
+
+ );
}
diff --git a/src/views/view.tsx b/src/views/view.tsx
index 2f1abeb8b..ef884cc1b 100644
--- a/src/views/view.tsx
+++ b/src/views/view.tsx
@@ -1,24 +1,26 @@
import React from 'react';
interface Props {
- title: string;
- children: React.ReactNode;
+ title: string;
+ children: React.ReactNode;
}
export default function View(props: Props) {
- return (
- <>
-
-
-
-
- {props.title}
-
-
+ return (
+ <>
+
+
+
+
+
+ {props.title}
+
+
+
- {props.children}
-
-
- >
- );
+ {props.children}
+
+
+ >
+ );
}
diff --git a/tests/adminFunctions.spec.ts b/tests/adminFunctions.spec.ts
new file mode 100644
index 000000000..8ebab8d18
--- /dev/null
+++ b/tests/adminFunctions.spec.ts
@@ -0,0 +1,157 @@
+import { test, expect } from 'playwright-test-coverage';
+import { Page } from '@playwright/test';
+import { basicInit } from './helpers';
+
+async function loginAsAdminAndOpenDashboard(page: Page) {
+ await page.getByRole('link', { name: 'Login' }).click();
+ await page.getByRole('textbox', { name: 'Email address' }).fill('a@jwt.com');
+ await page.getByRole('textbox', { name: 'Password' }).fill('admin');
+ await page.getByRole('button', { name: 'Login' }).click();
+ await page.getByRole('link', { name: 'Admin' }).click();
+}
+
+test('create and close franchise', async ({ page }) => {
+ await basicInit(page);
+
+ await loginAsAdminAndOpenDashboard(page);
+
+ await expect(page.getByText("Mama Ricci's kitchen")).toBeVisible();
+ await expect(page.getByText('LotaPizza')).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'Lehi' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'Springville' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'American Fork' })).toBeVisible();
+
+ // Create franchise
+ await page.getByRole('button', { name: 'Add Franchise' }).click();
+ await page.getByRole('textbox', { name: 'franchise name' }).click();
+ await page.getByRole('textbox', { name: 'franchise name' }).fill('newFranchise');
+ await page.getByRole('textbox', { name: 'franchisee admin email' }).click();
+ await page.getByRole('textbox', { name: 'franchisee admin email' }).fill('a@jwt.com');
+ await page.getByRole('button', { name: 'Create' }).click();
+ await Promise.all([
+ page.waitForRequest((r) => r.url().includes('/api/franchise') && r.method() === 'GET'),
+ ]);
+ await expect(page.getByRole('cell', { name: 'newFranchise' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'Pizza Admin' })).toBeVisible();
+ await page
+ .getByRole('row', { name: 'newFranchise Pizza Admin Close' })
+ .getByRole('button')
+ .click();
+ await expect(page.getByText('Sorry to see you go')).toBeVisible();
+ await expect(page.getByText('newFranchise')).toBeVisible();
+ await page.getByRole('button', { name: 'Close' }).click();
+ await expect(page.getByRole('cell', { name: 'LotaPizza' })).toBeVisible();
+});
+
+test('create and close store as admin', async ({ page }) => {
+ await basicInit(page);
+
+ await loginAsAdminAndOpenDashboard(page);
+
+ // Close Spanish Fork store
+ await page.getByRole('row', { name: 'Lehi 450 ₿ Close' }).getByRole('button').click();
+ await expect(page.getByRole('main')).toContainText(
+ 'Are you sure you want to close the LotaPizza store Lehi ? This cannot be restored. All outstanding revenue will not be refunded.',
+ );
+ await page.getByRole('button', { name: 'Close' }).click();
+ await expect(page.getByRole('cell', { name: 'Lehi' })).toHaveCount(0);
+});
+
+test('admin can switch between franchises and users lists', async ({ page }) => {
+ await basicInit(page);
+ await loginAsAdminAndOpenDashboard(page);
+
+ await expect(page.getByRole('button', { name: 'Franchises' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'LotaPizza' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Users' }).click();
+ await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'Pizza Admin' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'a@jwt.com' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Add Franchise' })).toHaveCount(0);
+
+ await page.getByRole('button', { name: 'Franchises' }).click();
+ await expect(page.getByRole('cell', { name: 'LotaPizza' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Add Franchise' })).toBeVisible();
+});
+
+test('admin can filter users list by name and email', async ({ page }) => {
+ await basicInit(page);
+ await loginAsAdminAndOpenDashboard(page);
+
+ await page.getByRole('button', { name: 'Users' }).click();
+ await expect(page.getByRole('cell', { name: 'Pizza Admin' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'Pizza Diner' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'Pizza Franchisee' })).toBeVisible();
+
+ await page.getByPlaceholder('Filter users').fill('franchisee');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await expect(page.getByRole('cell', { name: 'Pizza Franchisee' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'Pizza Admin' })).toHaveCount(0);
+
+ await page.getByPlaceholder('Filter users').fill('a@jwt.com');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ const adminEmailCells = await page.getByRole('cell', { name: 'a@jwt.com' }).count();
+ expect(adminEmailCells).toBeGreaterThanOrEqual(1);
+ await expect(page.getByRole('cell', { name: 'f@jwt.com' })).toHaveCount(0);
+});
+
+test('users list has no close actions and list filter resets when switching back', async ({
+ page,
+}) => {
+ await basicInit(page);
+ await loginAsAdminAndOpenDashboard(page);
+
+ await page.getByRole('button', { name: 'Users' }).click();
+ await expect(page.getByRole('button', { name: 'Close' })).toHaveCount(0);
+ await expect(page.getByRole('button', { name: 'Add Franchise' })).toHaveCount(0);
+
+ await page.getByPlaceholder('Filter users').fill('franchisee');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await expect(page.getByRole('cell', { name: 'Pizza Franchisee' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'Pizza Admin' })).toHaveCount(0);
+
+ await page.getByRole('button', { name: 'Franchises' }).click();
+ await expect(page.getByPlaceholder('Filter franchises')).toHaveValue('');
+ await expect(page.getByRole('cell', { name: 'LotaPizza' })).toBeVisible();
+});
+
+test('admin can delete a user from users list', async ({ page }) => {
+ await basicInit(page);
+ await loginAsAdminAndOpenDashboard(page);
+
+ await page.getByRole('button', { name: 'Users' }).click();
+ await expect(page.getByRole('cell', { name: 'Pizza Diner' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'd@jwt.com' })).toBeVisible();
+
+ await page
+ .getByRole('row', { name: 'Pizza Diner d@jwt.com Delete' })
+ .getByRole('button', { name: 'Delete' })
+ .click();
+ await expect(page.getByText('Are you sure you want to delete user')).toBeVisible();
+ await expect(page.getByText('Pizza Diner')).toBeVisible();
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ await page.getByRole('button', { name: 'Users' }).click();
+ await expect(page.getByRole('cell', { name: 'Pizza Diner' })).toHaveCount(0);
+ await expect(page.getByRole('cell', { name: 'd@jwt.com' })).toHaveCount(0);
+ await expect(page.getByRole('cell', { name: 'Pizza Admin' })).toBeVisible();
+});
+
+test('admin can cancel delete user and keep user in list', async ({ page }) => {
+ await basicInit(page);
+ await loginAsAdminAndOpenDashboard(page);
+
+ await page.getByRole('button', { name: 'Users' }).click();
+ await page
+ .getByRole('row', { name: 'Pizza Diner d@jwt.com Delete' })
+ .getByRole('button', { name: 'Delete' })
+ .click();
+
+ await expect(page.getByText('Are you sure you want to delete user')).toBeVisible();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ await page.getByRole('button', { name: 'Users' }).click();
+ await expect(page.getByRole('cell', { name: 'Pizza Diner' })).toBeVisible();
+ await expect(page.getByRole('cell', { name: 'd@jwt.com' })).toBeVisible();
+});
diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts
new file mode 100644
index 000000000..6fc44b89b
--- /dev/null
+++ b/tests/auth.spec.ts
@@ -0,0 +1,68 @@
+import { test, expect } from 'playwright-test-coverage';
+import { basicInit } from './helpers';
+
+test('login and logout', async ({ page }) => {
+ await basicInit(page);
+
+ // login, check logged in
+ await page.getByRole('link', { name: 'Login' }).click();
+ await page.getByRole('textbox', { name: 'Email address' }).fill('d@jwt.com');
+ await page.getByRole('textbox', { name: 'Password' }).fill('a');
+ await page.getByRole('button', { name: 'Login' }).click();
+
+ await expect(page.getByRole('link', { name: 'PD' })).toBeVisible();
+
+ // Logout, check logged out
+ await page.getByRole('link', { name: 'Logout' }).click();
+ await expect(page.getByLabel('Global')).toContainText('JWT PizzaOrderFranchiseLoginRegister');
+});
+
+test('purchase with login', async ({ page }) => {
+ await basicInit(page);
+
+ // Go to order page
+ await page.getByRole('button', { name: 'Order now' }).click();
+
+ // Create order
+ await expect(page.locator('h2')).toContainText('Awesome is a click away');
+ await page.getByRole('combobox').selectOption('4');
+ await page.getByRole('link', { name: 'Image Description Veggie A' }).click();
+ await page.getByRole('link', { name: 'Image Description Pepperoni' }).click();
+ await expect(page.locator('form')).toContainText('Selected pizzas: 2');
+ await page.getByRole('button', { name: 'Checkout' }).click();
+
+ // Login
+ await page.getByPlaceholder('Email address').click();
+ await page.getByPlaceholder('Email address').fill('d@jwt.com');
+ await page.getByPlaceholder('Email address').press('Tab');
+ await page.getByPlaceholder('Password').fill('a');
+ await page.getByRole('button', { name: 'Login' }).click();
+
+ // Pay
+ await expect(page.getByRole('main')).toContainText('Send me those 2 pizzas right now!');
+ await expect(page.locator('tbody')).toContainText('Veggie');
+ await expect(page.locator('tbody')).toContainText('Pepperoni');
+ await expect(page.locator('tfoot')).toContainText('0.008 ₿');
+ await page.getByRole('button', { name: 'Pay now' }).click();
+
+ // Check balance
+ await expect(page.getByText('0.008')).toBeVisible();
+});
+
+test('register', async ({ page }) => {
+ await basicInit(page);
+
+ // Register new user
+ await page.getByRole('link', { name: 'Register' }).click();
+ await expect(page.getByText('Welcome to the party')).toBeVisible();
+ await page.getByPlaceholder('Full name').fill('New Name');
+ await page.getByPlaceholder('Email address').fill('newEmail@test.com');
+ await page.getByPlaceholder('Password').fill('newPassword');
+ await page.getByRole('button', { name: 'Register' }).click();
+
+ // Diner Dashboard
+ await page.getByRole('link', { name: 'NN' }).click();
+ await expect(page.getByText('New Name')).toBeVisible();
+ await expect(page.getByText('newEmail@test.com')).toBeVisible();
+ await expect(page.getByText('diner', { exact: true })).toBeVisible();
+});
diff --git a/tests/franchiseeFunctions.spec.ts b/tests/franchiseeFunctions.spec.ts
new file mode 100644
index 000000000..a4f5097bf
--- /dev/null
+++ b/tests/franchiseeFunctions.spec.ts
@@ -0,0 +1,32 @@
+import { test, expect } from 'playwright-test-coverage';
+import { basicInit } from './helpers';
+
+test('create and close store as franchisee', async ({ page }) => {
+ await basicInit(page);
+
+ await page.getByRole('link', { name: 'Login' }).click();
+ await page.getByRole('textbox', { name: 'Email address' }).fill('f@jwt.com');
+ await page.getByRole('textbox', { name: 'Password' }).fill('f');
+ await page.getByRole('button', { name: 'Login' }).click();
+ await page
+ .getByRole('navigation', { name: 'Global' })
+ .getByRole('link', { name: 'Franchise' })
+ .click();
+ await expect(page.getByText('LotaPizza')).toBeVisible();
+ await expect(page.getByText('Lehi')).toBeVisible();
+
+ // Create and close new store
+ await page.getByRole('button', { name: 'Create store' }).click();
+ await expect(page.getByText('Create store')).toBeVisible();
+ await page.getByRole('textbox', { name: 'store name' }).fill('newStore');
+ await page.getByRole('button', { name: 'Create' }).click();
+ await Promise.all([page.waitForResponse((r) => r.url().includes('/api/franchise'))]);
+ await expect(page.locator('tbody')).toContainText('newStore');
+ await page.getByRole('row', { name: 'newStore 0 ₿ Close' }).getByRole('button').click();
+ await expect(page.getByRole('main')).toContainText(
+ 'Are you sure you want to close the LotaPizza store newStore ? This cannot be restored. All outstanding revenue will not be refunded.',
+ );
+ await page.getByRole('button', { name: 'Close' }).click();
+ await Promise.all([page.waitForResponse((r) => r.url().includes('/api/franchise'))]);
+ await expect(page.locator('tbody')).not.toContainText('newStore');
+});
diff --git a/tests/helpers.ts b/tests/helpers.ts
new file mode 100644
index 000000000..bb4fa3af0
--- /dev/null
+++ b/tests/helpers.ts
@@ -0,0 +1,305 @@
+import { Page } from '@playwright/test';
+import { Role, User } from '../src/service/pizzaService';
+import { expect } from 'playwright-test-coverage';
+
+async function basicInit(page: Page) {
+ let loggedInUser: User | undefined;
+
+ await page.addInitScript(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+ const validUsers: Record
= {
+ 'd@jwt.com': {
+ id: '3',
+ name: 'Pizza Diner',
+ email: 'd@jwt.com',
+ password: 'a',
+ roles: [{ role: Role.Diner }],
+ },
+ 'f@jwt.com': {
+ id: '4',
+ name: 'Pizza Franchisee',
+ email: 'f@jwt.com',
+ password: 'f',
+ roles: [{ role: Role.Franchisee }],
+ },
+ 'a@jwt.com': {
+ id: '5',
+ name: 'Pizza Admin',
+ email: 'a@jwt.com',
+ password: 'admin',
+ roles: [{ role: Role.Admin }],
+ },
+ };
+
+ let validFranchises: {
+ id: string;
+ name: string;
+ stores: { id: string; name: string; franchiseId: string; totalRevenue?: number }[];
+ admins: User[];
+ }[] = [
+ {
+ id: '2',
+ name: 'LotaPizza',
+ stores: [
+ { id: '4', name: 'Lehi', franchiseId: '2', totalRevenue: 450 },
+ { id: '5', name: 'Springville', franchiseId: '2', totalRevenue: 5 },
+ { id: '6', name: 'American Fork', franchiseId: '2', totalRevenue: 890 },
+ ],
+ admins: [{ id: '4', name: 'Pizza Franchisee', email: 'f@jwt.com' }],
+ },
+ {
+ id: '3',
+ name: 'PizzaCorp',
+ stores: [{ id: '7', name: 'Spanish Fork', franchiseId: '3', totalRevenue: 0 }],
+ admins: [],
+ },
+ { id: '4', name: 'topSpot', stores: [], admins: [] },
+ ];
+
+ // Authorize login for the given user
+ await page.route('*/**/api/auth', async (route) => {
+ if (route.request().method() === 'DELETE') {
+ loggedInUser = undefined;
+ await route.fulfill({ json: { message: 'logged out successfully' } });
+ return;
+ }
+ const req = route.request().postDataJSON();
+ if (route.request().method() === 'PUT') {
+ const user = validUsers[req.email];
+ if (!user || user.password !== req.password) {
+ await route.fulfill({ status: 401, json: { error: 'Unauthorized' } });
+ return;
+ }
+ loggedInUser = { ...validUsers[req.email] };
+ delete loggedInUser.password;
+ const loginRes = {
+ user: loggedInUser,
+ token: 'abcdef',
+ };
+ expect(route.request().method()).toBe('PUT');
+ await route.fulfill({ json: loginRes });
+ return;
+ }
+ const newUser = { ...req, id: '15', roles: [{ role: Role.Diner }] };
+ validUsers[req.email] = { ...newUser };
+ const userResponse = { ...newUser };
+ delete userResponse.password;
+ loggedInUser = userResponse;
+ await route.fulfill({ json: { user: userResponse, token: 'abcdef' } });
+ });
+
+ // Return the currently logged in user
+ await page.route('*/**/api/user/me', async (route) => {
+ expect(route.request().method()).toBe('GET');
+ await route.fulfill({ json: loggedInUser });
+ });
+
+ // List users
+ await page.route(/\/api\/user(\?.*)$/, async (route) => {
+ expect(route.request().method()).toBe('GET');
+ const url = new URL(route.request().url());
+ const nameFilter = (url.searchParams.get('name') || '*').replace(/\*/g, '').toLowerCase();
+
+ const users = Object.values(validUsers)
+ .filter((user) => {
+ if (!nameFilter) {
+ return true;
+ }
+ const userName = (user.name || '').toLowerCase();
+ const userEmail = (user.email || '').toLowerCase();
+ return userName.includes(nameFilter) || userEmail.includes(nameFilter);
+ })
+ .map((user) => {
+ const responseUser = { ...user };
+ delete responseUser.password;
+ return responseUser;
+ });
+
+ await route.fulfill({ json: { users, more: false } });
+ });
+
+ // Update a user
+ await page.route(/\/api\/user\/(\d+)$/, async (route) => {
+ const method = route.request().method();
+ const userId = route.request().url().split('/').pop();
+
+ if (!userId) {
+ await route.fulfill({ status: 400, json: { error: 'missing user id' } });
+ return;
+ }
+
+ const currentEntry = Object.entries(validUsers).find(([, user]) => user.id === userId);
+ if (!currentEntry) {
+ await route.fulfill({ status: 404, json: { error: 'user not found' } });
+ return;
+ }
+
+ if (method === 'DELETE') {
+ const [currentEmail, currentUser] = currentEntry;
+ delete validUsers[currentEmail];
+ if (loggedInUser?.id === currentUser.id) {
+ loggedInUser = undefined;
+ }
+ await route.fulfill({ json: { message: 'user deleted successfully' } });
+ return;
+ }
+
+ expect(method).toBe('PUT');
+ const userReq = route.request().postDataJSON();
+ const [currentEmail, currentUser] = currentEntry;
+ const updatedUser: User = {
+ ...currentUser,
+ ...userReq,
+ id: currentUser.id,
+ roles: userReq.roles || currentUser.roles,
+ password: userReq.password || currentUser.password,
+ };
+
+ if (userReq.email && userReq.email !== currentEmail) {
+ delete validUsers[currentEmail];
+ }
+ validUsers[updatedUser.email || currentEmail] = updatedUser;
+
+ const userResponse = { ...updatedUser };
+ delete userResponse.password;
+ loggedInUser = userResponse;
+ await route.fulfill({ json: { user: userResponse, token: 'abcdef' } });
+ });
+
+ // A standard menu
+ await page.route('*/**/api/order/menu', async (route) => {
+ const menuRes = [
+ {
+ id: '1',
+ title: 'Veggie',
+ image: 'pizza1.png',
+ price: 0.0038,
+ description: 'A garden of delight',
+ },
+ {
+ id: '2',
+ title: 'Pepperoni',
+ image: 'pizza2.png',
+ price: 0.0042,
+ description: 'Spicy treat',
+ },
+ ];
+ expect(route.request().method()).toBe('GET');
+ await route.fulfill({ json: menuRes });
+ });
+
+ // Create a franchise
+ await page.route(/\/api\/franchise$/, async (route) => {
+ expect(route.request().method()).toBe('POST');
+ const franchiseReq = route.request().postDataJSON();
+ expect(franchiseReq.admins[0].email).toBe(loggedInUser?.email);
+ expect(franchiseReq.name).toBe('newFranchise');
+ const newFranchise = {
+ name: franchiseReq.name,
+ id: '8',
+ admins: [
+ {
+ name: loggedInUser?.name || '',
+ email: franchiseReq.admins[0].email,
+ id: loggedInUser?.id,
+ },
+ ],
+ };
+ validFranchises.push({ ...newFranchise, stores: [] });
+ await route.fulfill({ json: newFranchise });
+ });
+
+ // Delete a franchise or get a user's franchises
+ await page.route(/\/api\/franchise\/(\d+)$/, async (route) => {
+ const franchiseId = route.request().url().split('/').pop();
+ if (franchiseId) {
+ if (route.request().method() === 'DELETE') {
+ validFranchises = validFranchises.filter((f) => f.id !== franchiseId);
+ await route.fulfill({ json: { message: 'franchise deleted' } });
+ return;
+ } else if (route.request().method() === 'GET') {
+ const userId = franchiseId;
+ const franchises = validFranchises.filter((f) =>
+ f.admins.find((a) => a.id === userId),
+ );
+ await route.fulfill({ json: franchises });
+ return;
+ }
+ } else {
+ await route.fulfill({ status: 400, json: { error: 'missing franchise id' } });
+ }
+ });
+
+ // Get franchises
+ await page.route(/\/api\/franchise(\?.*)$/, async (route) => {
+ const franchiseRes = {
+ franchises: validFranchises,
+ };
+ expect(route.request().method()).toBe('GET');
+ await route.fulfill({ json: franchiseRes });
+ });
+
+ // Order a pizza.
+ await page.route('*/**/api/order', async (route) => {
+ if (route.request().method() === 'POST') {
+ const orderReq = route.request().postDataJSON();
+ const orderRes = {
+ order: { ...orderReq, id: 23 },
+ jwt: 'eyJpYXQ',
+ };
+ await route.fulfill({ json: orderRes });
+ }
+ });
+
+ // Delete and Create store
+ await page.route(/\/api\/franchise\/(\d+)\/store(\/\d+)?/, async (route) => {
+ if (route.request().method() === 'DELETE') {
+ const storeId = route.request().url().split('/').pop();
+ const franchiseId = route
+ .request()
+ .url()
+ .match(/\/franchise\/(\d+)\/store/)?.[1];
+ if (!storeId || !franchiseId) {
+ await route.fulfill({
+ status: 400,
+ json: { error: 'missing store or franchise id' },
+ });
+ return;
+ }
+ const franchise = validFranchises.find((f) => f.id === franchiseId);
+ if (!franchise) {
+ await route.fulfill({ status: 404, json: { error: 'franchise not found' } });
+ return;
+ }
+ franchise.stores = franchise.stores.filter((s) => s.id !== storeId);
+ await route.fulfill({ json: { message: 'store deleted' } });
+ return;
+ } else if (route.request().method() === 'POST') {
+ const storeReq = route.request().postDataJSON();
+ const franchiseId = route
+ .request()
+ .url()
+ .match(/\/franchise\/(\d+)\/store/)?.[1];
+ if (!franchiseId) {
+ await route.fulfill({ status: 400, json: { error: 'missing franchise id' } });
+ return;
+ }
+ const newStore = { name: storeReq.name, id: '12', franchiseId, totalRevenue: 0 };
+ const franchise = validFranchises.find((f) => f.id === franchiseId);
+ if (!franchise) {
+ await route.fulfill({ status: 404, json: { error: 'franchise not found' } });
+ return;
+ }
+ franchise.stores.push(newStore);
+ await route.fulfill({
+ json: { id: newStore.id, name: newStore.name, franchiseId },
+ });
+ }
+ });
+
+ await page.goto('/');
+}
+
+export { basicInit };
diff --git a/tests/ui.spec.ts b/tests/ui.spec.ts
new file mode 100644
index 000000000..62bb4c6cb
--- /dev/null
+++ b/tests/ui.spec.ts
@@ -0,0 +1,18 @@
+import { test, expect } from 'playwright-test-coverage';
+
+test('history, about, docs', async ({ page }) => {
+ await page.goto('/');
+ // About
+ await page.getByRole('link', { name: 'About' }).click();
+ await expect(page.getByText('The secret sauce')).toBeVisible();
+ await expect(page.getByRole('img').nth(3)).toBeVisible();
+
+ // History
+ await page.getByRole('link', { name: 'History' }).click();
+ await expect(page.getByText('Mama Rucci, my my')).toBeVisible();
+ await expect(page.getByRole('main').getByRole('img')).toBeVisible();
+
+ // Docs
+ await page.goto('/docs');
+ await expect(page.getByText('JWT Pizza API')).toBeVisible();
+});
diff --git a/tests/user.spec.ts b/tests/user.spec.ts
new file mode 100644
index 000000000..9d7fb3daa
--- /dev/null
+++ b/tests/user.spec.ts
@@ -0,0 +1,91 @@
+import { test, expect } from 'playwright-test-coverage';
+import { Page } from '@playwright/test';
+import { basicInit } from './helpers';
+
+async function registerDiner(page: Page, email: string, password: string = 'diner') {
+ await page.goto('/');
+ await page.getByRole('link', { name: 'Register' }).click();
+ await page.getByRole('textbox', { name: 'Full name' }).fill('pizza diner');
+ await page.getByRole('textbox', { name: 'Email address' }).fill(email);
+ await page.getByRole('textbox', { name: 'Password' }).fill(password);
+ await page.getByRole('button', { name: 'Register' }).click();
+}
+
+async function openUserEditModal(page: Page) {
+ await page.getByRole('link', { name: 'pd' }).click();
+ await page.getByRole('button', { name: 'Edit' }).click();
+}
+
+async function logoutAndOpenLogin(page: Page) {
+ await page.getByRole('link', { name: 'Logout' }).click();
+ await page.getByRole('link', { name: 'Login' }).click();
+}
+
+test('updateUser', async ({ page }) => {
+ await basicInit(page);
+ const email = `user${Math.floor(Math.random() * 10000)}@jwt.com`;
+ await registerDiner(page, email);
+
+ await openUserEditModal(page);
+ await page.locator('#hs-jwt-modal input').first().fill('pizza dinerx');
+ await page.getByRole('button', { name: 'Update' }).click();
+
+ await page.waitForSelector('[role="dialog"].hidden', { state: 'attached' });
+
+ await expect(page.getByRole('main')).toContainText('pizza dinerx');
+
+ await logoutAndOpenLogin(page);
+
+ await page.getByRole('textbox', { name: 'Email address' }).fill(email);
+ await page.getByRole('textbox', { name: 'Password' }).fill('diner');
+ await page.getByRole('button', { name: 'Login' }).click();
+
+ await page.getByRole('link', { name: 'pd' }).click();
+
+ await expect(page.getByRole('main')).toContainText('pizza dinerx');
+});
+
+test('updateUser email persists across logout/login', async ({ page }) => {
+ await basicInit(page);
+ const email = `user${Math.floor(Math.random() * 10000)}@jwt.com`;
+ const updatedEmail = `updated${Math.floor(Math.random() * 10000)}@jwt.com`;
+ await registerDiner(page, email);
+
+ await openUserEditModal(page);
+ await page.locator('#hs-jwt-modal input').nth(1).fill(updatedEmail);
+ await page.getByRole('button', { name: 'Update' }).click();
+ await page.waitForSelector('[role="dialog"].hidden', { state: 'attached' });
+
+ await expect(page.getByRole('main')).toContainText(updatedEmail);
+
+ await logoutAndOpenLogin(page);
+ await page.getByRole('textbox', { name: 'Email address' }).fill(updatedEmail);
+ await page.getByRole('textbox', { name: 'Password' }).fill('diner');
+ await page.getByRole('button', { name: 'Login' }).click();
+ await page.getByRole('link', { name: 'pd' }).click();
+
+ await expect(page.getByRole('main')).toContainText(updatedEmail);
+});
+
+test('updateUser password invalidates old password', async ({ page }) => {
+ await basicInit(page);
+ const email = `user${Math.floor(Math.random() * 10000)}@jwt.com`;
+ const newPassword = 'newDinerPass';
+ await registerDiner(page, email);
+
+ await openUserEditModal(page);
+ await page.locator('#hs-jwt-modal input').nth(2).fill(newPassword);
+ await page.getByRole('button', { name: 'Update' }).click();
+ await page.waitForSelector('[role="dialog"].hidden', { state: 'attached' });
+
+ await logoutAndOpenLogin(page);
+ await page.getByRole('textbox', { name: 'Email address' }).fill(email);
+ await page.getByRole('textbox', { name: 'Password' }).fill('diner');
+ await page.getByRole('button', { name: 'Login' }).click();
+ await expect(page.getByText('Invalid email or password')).toBeVisible();
+
+ await page.getByRole('textbox', { name: 'Password' }).fill(newPassword);
+ await page.getByRole('button', { name: 'Login' }).click();
+ await page.getByRole('link', { name: 'pd' }).click();
+ await expect(page.getByRole('main')).toContainText('pizza diner');
+});
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 000000000..bf46b6ab1
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite';
+import istanbul from 'vite-plugin-istanbul';
+
+export default defineConfig({
+ build: { sourcemap: true },
+ plugins: [
+ istanbul({
+ include: ['src/**/*'],
+ exclude: ['node_modules'],
+ requireEnv: false,
+ }),
+ ],
+});
\ No newline at end of file