Initial commit

This commit is contained in:
Robert van Diest
2026-03-24 20:13:07 +01:00
commit 1301a01d6d
123 changed files with 7642 additions and 0 deletions

96
tests/e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,96 @@
{
"name": "randall-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "randall-e2e",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/node": "^22.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

15
tests/e2e/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "randall-e2e",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/node": "^22.0.0"
}
}

View File

@@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: false,
workers: 1, // tests share database state — never run in parallel
retries: 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
headless: !!process.env.CI,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test';
import { loginAsAdmin } from './helpers';
test.describe('Admin portal', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('admin user sees the Admin portal button in the header', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Admin portal' })).toBeVisible();
});
test('navigates to the admin portal', async ({ page }) => {
await page.getByRole('button', { name: 'Admin portal' }).click();
await page.waitForURL('/admin');
await expect(page.getByRole('heading', { name: 'Admin Portal' })).toBeVisible();
await expect(page.getByText('Manage user accounts')).toBeVisible();
});
test('admin portal lists the admin account under Approved accounts', async ({ page }) => {
await page.goto('/admin');
await expect(page.getByText('Approved accounts')).toBeVisible();
// Target the name paragraph inside the admin's list item
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
await expect(adminRow.getByRole('paragraph').filter({ hasText: /^Admin$/ })).toBeVisible();
});
test('admin account has the Admin badge and no Make admin button', async ({ page }) => {
await page.goto('/admin');
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
// The badge is a <span> with class bg-amber-100
await expect(adminRow.locator('span').filter({ hasText: /^Admin$/ })).toBeVisible();
await expect(adminRow.getByRole('button', { name: 'Make admin' })).not.toBeVisible();
});
test('Back to planner link returns to the planner', async ({ page }) => {
await page.goto('/admin');
await page.getByRole('button', { name: '← Back to planner' }).click();
await page.waitForURL('/');
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
});
});

View File

@@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test';
import { ADMIN_EMAIL, ADMIN_PASSWORD } from './helpers';
test.describe('Authentication', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('shows the sign-in form on initial load', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Office Planner' })).toBeVisible();
await expect(page.getByPlaceholder('jane@company.com')).toBeVisible();
await expect(page.getByPlaceholder('••••••••')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
});
test('signs in with valid credentials and reaches the planner', async ({ page }) => {
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"]').click();
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
});
test('shows an error for an unknown email', async ({ page }) => {
await page.getByPlaceholder('jane@company.com').fill('nobody@example.com');
await page.getByPlaceholder('••••••••').fill('anything');
await page.locator('button[type="submit"]').click();
await expect(page.getByText(/invalid email or password/i)).toBeVisible();
});
test('shows an error for a wrong password', async ({ page }) => {
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
await page.getByPlaceholder('••••••••').fill('wrongpassword');
await page.locator('button[type="submit"]').click();
await expect(page.getByText(/invalid email or password/i)).toBeVisible();
});
test('registers a new account and shows pending approval message', async ({ page }) => {
// Switch to register mode
await page.locator('button').filter({ hasText: 'Create account' }).first().click();
await page.getByPlaceholder('Jane Smith').fill('Test User');
await page.getByPlaceholder('jane@company.com').fill(`testuser+${Date.now()}@example.com`);
await page.getByPlaceholder('••••••••').fill('Test@1234');
await page.locator('button[type="submit"]').click();
await expect(page.getByText('Account pending approval')).toBeVisible();
await expect(page.getByText(/administrator will review/i)).toBeVisible();
});
test('signs out and returns to the sign-in form', async ({ page }) => {
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"]').click();
await page.waitForURL('/');
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
});
});

View File

@@ -0,0 +1,25 @@
import { type Page } from '@playwright/test';
export const ADMIN_EMAIL = 'admin@randall.local';
export const ADMIN_PASSWORD = 'Admin@123';
export const ADMIN_NAME = 'Admin';
export async function loginAs(page: Page, email: string, password: string) {
await page.goto('/');
await page.getByPlaceholder('jane@company.com').fill(email);
await page.getByPlaceholder('••••••••').fill(password);
await page.locator('button[type="submit"]').click();
await page.waitForURL('/');
await page.getByText('Reserve your workspace up to 2 weeks ahead').waitFor();
}
export async function loginAsAdmin(page: Page) {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
}
/** Returns today's date offset by `days` in yyyy-MM-dd format */
export function offsetDate(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().split('T')[0];
}

View File

@@ -0,0 +1,100 @@
import { test, expect } from '@playwright/test';
import { loginAsAdmin, offsetDate } from './helpers';
test.describe('Planner', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('displays both pods with 8 desks each', async ({ page }) => {
// Wait for floor plan to load — at least one free desk must be visible
await expect(page.getByRole('button', { name: 'D1 Free' })).toBeVisible();
// All 16 desk buttons contain a desk label (D1D16) in their text content
const allDesks = page.getByRole('button').filter({ hasText: /D\d+/ });
await expect(allDesks).toHaveCount(16);
});
test('date picker is constrained to today and 14 days ahead', async ({ page }) => {
const input = page.locator('input[type="date"]');
const today = offsetDate(0);
const max = offsetDate(14);
await expect(input).toHaveAttribute('min', today);
await expect(input).toHaveAttribute('max', max);
});
test('previous-day button is disabled when on today', async ({ page }) => {
await expect(page.getByRole('button', { name: '←' })).toBeDisabled();
});
test('next-day button is disabled when on the maximum date', async ({ page }) => {
const input = page.locator('input[type="date"]');
await input.fill(offsetDate(14));
await input.dispatchEvent('change');
await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
});
test('reserves a desk and shows it as Mine', async ({ page }) => {
const targetDate = offsetDate(7);
const input = page.locator('input[type="date"]');
await input.fill(targetDate);
await input.dispatchEvent('change');
await page.getByRole('button', { name: 'D1 Free' }).click();
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('button', { name: /^D1\s+Mine/ })).toBeVisible();
// Clean up
await page.getByRole('button', { name: /^D1\s+Mine/ }).click();
await page.getByRole('button', { name: 'Cancel reservation' }).click();
});
test('reserved desk appears in My reservations', async ({ page }) => {
const targetDate = offsetDate(8);
const input = page.locator('input[type="date"]');
await input.fill(targetDate);
await input.dispatchEvent('change');
await page.getByRole('button', { name: 'D2 Free' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('heading', { name: 'My reservations' })).toBeVisible();
await expect(page.locator('section').filter({ hasText: 'My reservations' }).getByText('D2')).toBeVisible();
// Clean up
await page.getByRole('button', { name: /^D2\s+Mine/ }).click();
await page.getByRole('button', { name: 'Cancel reservation' }).click();
});
test('cancels a reservation and desk returns to Free', async ({ page }) => {
const targetDate = offsetDate(9);
const input = page.locator('input[type="date"]');
await input.fill(targetDate);
await input.dispatchEvent('change');
// Reserve
await page.getByRole('button', { name: 'D3 Free' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('button', { name: /^D3\s+Mine/ })).toBeVisible();
// Cancel
await page.getByRole('button', { name: /^D3\s+Mine/ }).click();
await expect(page.getByRole('heading', { name: 'Cancel reservation' })).toBeVisible();
await page.getByRole('button', { name: 'Cancel reservation' }).click();
await expect(page.getByRole('button', { name: 'D3 Free' })).toBeVisible();
});
test('dismisses the reservation modal when clicking Cancel', async ({ page }) => {
await page.getByRole('button', { name: 'D4 Free' }).click();
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
// Scope to the modal overlay to avoid matching Cancel buttons in My reservations
await page.locator('.fixed.inset-0').getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('heading', { name: 'Reserve desk' })).not.toBeVisible();
});
});

15
tests/e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts"]
}