diff --git a/backend/src/database/init.js b/backend/src/database/init.js index 2de9a347..e424f93c 100644 --- a/backend/src/database/init.js +++ b/backend/src/database/init.js @@ -27,6 +27,9 @@ async function initializeDatabase() { return new Promise((resolve, reject) => { database.serialize(() => { + // Required for the ON DELETE CASCADE constraints below to be enforced + database.run('PRAGMA foreign_keys = ON'); + // Create users table database.run(` CREATE TABLE IF NOT EXISTS users ( diff --git a/backend/src/routes/workEntries.js b/backend/src/routes/workEntries.js index 63649d73..4f308e5f 100644 --- a/backend/src/routes/workEntries.js +++ b/backend/src/routes/workEntries.js @@ -5,6 +5,15 @@ const { workEntrySchema, updateWorkEntrySchema } = require('../validation/schema const router = express.Router(); +// Dates are persisted as ISO calendar days (YYYY-MM-DD) so that exports and +// clients receive a date rather than a timestamp. +function toDateOnly(value) { + if (value instanceof Date) { + return value.toISOString().split('T')[0]; + } + return String(value).split('T')[0]; +} + // All routes require authentication router.use(authenticateUser); @@ -104,7 +113,7 @@ router.post('/', (req, res, next) => { // Create work entry db.run( 'INSERT INTO work_entries (client_id, user_email, hours, description, date) VALUES (?, ?, ?, ?, ?)', - [clientId, req.userEmail, hours, description || null, date], + [clientId, req.userEmail, hours, description || null, toDateOnly(date)], function(err) { if (err) { console.error('Database error:', err); @@ -214,7 +223,7 @@ router.put('/:id', (req, res, next) => { if (value.date !== undefined) { updates.push('date = ?'); - values.push(value.date); + values.push(toDateOnly(value.date)); } updates.push('updated_at = CURRENT_TIMESTAMP'); diff --git a/backend/src/server.js b/backend/src/server.js index e267f487..e036dbc9 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -24,8 +24,8 @@ app.use(cors({ // Rate limiting const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100 // limit each IP to 100 requests per windowMs + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000, // 15 minutes + max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100 // limit each IP to this many requests per window }); app.use(limiter); diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..97058260 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +test-results/ +playwright-report/ +blob-report/ +recordings/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..d7bae57d --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,22 @@ +# End-to-end tests + +Playwright tests covering login, client management, the work entry lifecycle, +reporting and input edge cases. + +## Running + +```bash +cd e2e +npm install +npx playwright install chromium +npm test # starts backend (3001) + frontend (5173) automatically +npm run report # open the HTML report +``` + +The Playwright `webServer` config starts the backend with a raised +`RATE_LIMIT_MAX` so a full suite run is not throttled, and reuses servers that +are already listening on those ports. Set `E2E_NO_WEBSERVER=1` to run against +servers you started yourself. + +Because the backend uses an in-memory SQLite database keyed by user email, every +test logs in with a unique email and therefore gets isolated data. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000..6cc8d297 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "e2e", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.56.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..a7f78953 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "timesheet-e2e", + "version": "1.0.0", + "private": true, + "description": "Playwright end-to-end tests for the time tracking app", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui", + "report": "playwright show-report" + }, + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.56.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..d0119c76 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +const FRONTEND_URL = process.env.E2E_BASE_URL || 'http://localhost:5173'; +const BACKEND_URL = process.env.E2E_API_URL || 'http://localhost:3001'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: [['list'], ['html', { open: 'never' }]], + timeout: 60_000, + expect: { timeout: 10_000 }, + use: { + baseURL: FRONTEND_URL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: process.env.E2E_NO_WEBSERVER + ? undefined + : [ + { + command: 'npm start', + cwd: '../backend', + url: `${BACKEND_URL}/health`, + reuseExistingServer: true, + timeout: 60_000, + env: { + PORT: '3001', + NODE_ENV: 'development', + FRONTEND_URL, + RATE_LIMIT_MAX: '100000', + }, + }, + { + command: 'npm run dev -- --port 5173 --strictPort', + cwd: '../frontend', + url: FRONTEND_URL, + reuseExistingServer: true, + timeout: 60_000, + }, + ], +}); diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 00000000..8a1beb08 --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; +import { login, uniqueEmail } from './helpers'; + +test.describe('Login flow', () => { + test('valid credentials log the user in', async ({ page }) => { + const email = uniqueEmail('login-ok'); + await login(page, email); + + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByText(email)).toBeVisible(); + }); + + test('invalid credentials show an error and stay on the login page', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Email Address').fill('not-an-email'); + await page.getByRole('button', { name: 'Log In' }).click(); + + await expect(page.getByRole('alert').filter({ hasText: /Validation error|valid email|Login failed/i })).toBeVisible(); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Time Tracker' })).toBeVisible(); + }); + + test('unauthenticated visitors are redirected to login', async ({ page }) => { + await page.goto('/clients'); + await expect(page).toHaveURL(/\/login$/); + }); + + test('logout returns the user to the login page', async ({ page }) => { + await login(page, uniqueEmail('logout')); + await page.getByRole('button', { name: 'Logout' }).click(); + await expect(page).toHaveURL(/\/login$/); + }); +}); diff --git a/e2e/tests/clients.spec.ts b/e2e/tests/clients.spec.ts new file mode 100644 index 00000000..894542c7 --- /dev/null +++ b/e2e/tests/clients.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { acceptNextConfirm, createClient, gotoSection, login, uniqueEmail, uniqueName } from './helpers'; + +test.describe('Client management', () => { + test.beforeEach(async ({ page }) => { + await login(page, uniqueEmail('clients')); + await gotoSection(page, 'Clients'); + }); + + test('creates a client', async ({ page }) => { + const name = uniqueName('Acme'); + await createClient(page, name, { + department: 'Engineering', + email: 'contact@acme.com', + description: 'Primary client', + }); + + const row = page.getByRole('row', { name: new RegExp(name) }); + await expect(row).toContainText('Engineering'); + await expect(row).toContainText('contact@acme.com'); + await expect(row).toContainText('Primary client'); + }); + + test('edits a client', async ({ page }) => { + const name = uniqueName('Beta'); + const renamed = `${name} Renamed`; + await createClient(page, name, { department: 'Sales' }); + + await page.getByRole('row', { name: new RegExp(name) }).getByRole('button').first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('heading', { name: 'Edit Client' })).toBeVisible(); + await dialog.getByLabel('Client Name').fill(renamed); + await dialog.getByLabel('Department').fill('Marketing'); + await dialog.getByRole('button', { name: 'Update' }).click(); + await expect(dialog).toBeHidden(); + + const row = page.getByRole('row', { name: new RegExp(renamed) }); + await expect(row).toContainText('Marketing'); + await expect(page.getByRole('cell', { name, exact: true })).toHaveCount(0); + }); + + test('deletes a client', async ({ page }) => { + const name = uniqueName('Gamma'); + await createClient(page, name); + + acceptNextConfirm(page); + await page.getByRole('row', { name: new RegExp(name) }).getByRole('button').last().click(); + + await expect(page.getByRole('cell', { name, exact: true })).toHaveCount(0); + await expect(page.getByText('No clients found. Create your first client to get started.')).toBeVisible(); + }); +}); diff --git a/e2e/tests/edge-cases.spec.ts b/e2e/tests/edge-cases.spec.ts new file mode 100644 index 00000000..0b3e1ebd --- /dev/null +++ b/e2e/tests/edge-cases.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; +import { + alertText, + createClient, + createWorkEntry, + gotoSection, + hoursInput, + login, + selectClientInDialog, + uniqueEmail, + uniqueName, +} from './helpers'; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +test.describe('Edge cases', () => { + test('login button is disabled with an empty email', async ({ page }) => { + await page.goto('/login'); + await expect(page.getByRole('button', { name: 'Log In' })).toBeDisabled(); + }); + + test('blank client name shows a validation error', async ({ page }) => { + await login(page, uniqueEmail('edge-client-empty')); + await gotoSection(page, 'Clients'); + + await page.getByRole('button', { name: 'Add Client' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Client Name').fill(' '); + await dialog.getByRole('button', { name: 'Create' }).click(); + + await expect(alertText(page)).toContainText('Client name is required'); + await expect(dialog).toBeVisible(); + }); + + test('work entry without a client selected shows a validation error', async ({ page }) => { + const clientName = uniqueName('EdgeEntry'); + await login(page, uniqueEmail('edge-entry-empty')); + await gotoSection(page, 'Clients'); + await createClient(page, clientName); + await gotoSection(page, 'Work Entries'); + + await page.getByRole('button', { name: 'Add Work Entry' }).click(); + const dialog = page.getByRole('dialog'); + await hoursInput(page).fill('5'); + await dialog.getByRole('button', { name: 'Create' }).click(); + + await expect(alertText(page)).toContainText('Please select a client'); + await expect(dialog).toBeVisible(); + }); + + test('hours above the 24 hour maximum are not accepted', async ({ page }) => { + const clientName = uniqueName('EdgeHours'); + await login(page, uniqueEmail('edge-hours')); + await gotoSection(page, 'Clients'); + await createClient(page, clientName); + await gotoSection(page, 'Work Entries'); + + await page.getByRole('button', { name: 'Add Work Entry' }).click(); + const dialog = page.getByRole('dialog'); + await selectClientInDialog(page, clientName); + const hours = hoursInput(page); + await hours.fill('25'); + await dialog.getByRole('button', { name: 'Create' }).click(); + + await expect(dialog).toBeVisible(); + expect(await hours.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(false); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByText('No work entries found. Add your first work entry to get started.')).toBeVisible(); + }); + + test('special characters are preserved in client and entry text', async ({ page }) => { + const name = `${uniqueName('Ünïcødé')} & "Ltd" 100%`; + const description = "Quote ' and semicolon ; DROP TABLE clients; -- 日本語"; + await login(page, uniqueEmail('edge-special')); + + await gotoSection(page, 'Clients'); + await createClient(page, name, { description }); + await expect(page.getByRole('row', { name: new RegExp(escapeRegex(name)) })).toContainText(description); + + await gotoSection(page, 'Work Entries'); + await createWorkEntry(page, name, '1.5', description); + await expect(page.getByRole('row', { name: new RegExp(escapeRegex(description)) })).toContainText(name); + + await page.reload(); + await expect(page.getByRole('row', { name: new RegExp(escapeRegex(description)) })).toBeVisible(); + }); + + test('over-long client name and description are rejected by validation', async ({ page }) => { + await login(page, uniqueEmail('edge-long')); + await gotoSection(page, 'Clients'); + + await page.getByRole('button', { name: 'Add Client' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Client Name').fill('N'.repeat(300)); + await dialog.getByRole('button', { name: 'Create' }).click(); + await expect(alertText(page)).toContainText(/Validation error|name/i); + + await dialog.getByLabel('Client Name').fill(uniqueName('LongDesc')); + await dialog.getByLabel('Description').fill('D'.repeat(1500)); + await dialog.getByRole('button', { name: 'Create' }).click(); + await expect(alertText(page)).toContainText(/Validation error|description/i); + await expect(dialog).toBeVisible(); + }); + + test('long but allowed text is accepted and displayed', async ({ page }) => { + const name = uniqueName('LongOk'); + const description = 'L'.repeat(900); + await login(page, uniqueEmail('edge-long-ok')); + + await gotoSection(page, 'Clients'); + await createClient(page, name, { description }); + await expect(page.getByRole('row', { name: new RegExp(name) })).toContainText(description); + }); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 00000000..680310b4 --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,70 @@ +import { Page, expect } from '@playwright/test'; + +let counter = 0; + +/** Unique email per test so tests never share client/work-entry data. */ +export function uniqueEmail(prefix = 'e2e'): string { + counter += 1; + return `${prefix}-${Date.now()}-${counter}@example.com`; +} + +export function uniqueName(prefix = 'Client'): string { + counter += 1; + return `${prefix} ${Date.now()}-${counter}`; +} + +export async function login(page: Page, email: string): Promise { + await page.goto('/login'); + await page.getByLabel('Email Address').fill(email); + await page.getByRole('button', { name: 'Log In' }).click(); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); +} + +export async function gotoSection(page: Page, name: 'Dashboard' | 'Clients' | 'Work Entries' | 'Reports'): Promise { + await page.getByRole('button', { name, exact: true }).click(); + await expect(page.getByRole('heading', { name, exact: true })).toBeVisible(); +} + +export async function createClient(page: Page, name: string, fields: { department?: string; email?: string; description?: string } = {}): Promise { + await page.getByRole('button', { name: 'Add Client' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Client Name').fill(name); + if (fields.department) await dialog.getByLabel('Department').fill(fields.department); + if (fields.email) await dialog.getByLabel('Email').fill(fields.email); + if (fields.description) await dialog.getByLabel('Description').fill(fields.description); + await dialog.getByRole('button', { name: 'Create' }).click(); + await expect(dialog).toBeHidden(); + await expect(page.getByRole('cell', { name, exact: true })).toBeVisible(); +} + +/** + * Alerts rendered by the pages sit behind the MUI modal, which marks the rest of + * the document aria-hidden, so they are matched by class rather than by role. + */ +export function alertText(page: Page) { + return page.locator('.MuiAlert-root').last(); +} + +export function hoursInput(page: Page) { + return page.getByRole('dialog').getByRole('spinbutton', { name: /Hours/ }); +} + +export async function selectClientInDialog(page: Page, clientName: string): Promise { + await page.getByRole('dialog').getByRole('combobox').click(); + await page.getByRole('option', { name: clientName, exact: true }).click(); +} + +export async function createWorkEntry(page: Page, clientName: string, hours: string, description?: string): Promise { + await page.getByRole('button', { name: 'Add Work Entry' }).click(); + const dialog = page.getByRole('dialog'); + await selectClientInDialog(page, clientName); + await hoursInput(page).fill(hours); + if (description) await dialog.getByLabel('Description').fill(description); + await dialog.getByRole('button', { name: 'Create' }).click(); + await expect(dialog).toBeHidden(); +} + +/** Accept the next window.confirm() dialog (used by delete actions). */ +export function acceptNextConfirm(page: Page): void { + page.once('dialog', (dialog) => dialog.accept()); +} diff --git a/e2e/tests/reports.spec.ts b/e2e/tests/reports.spec.ts new file mode 100644 index 00000000..912ab62d --- /dev/null +++ b/e2e/tests/reports.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; +import { + acceptNextConfirm, + createClient, + createWorkEntry, + gotoSection, + login, + uniqueEmail, + uniqueName, +} from './helpers'; + +const summaryCard = (page: import('@playwright/test').Page, title: string) => + page.locator('.MuiCard-root').filter({ hasText: title }); + +test.describe('Reporting', () => { + test('report totals reflect the entries created and deleted', async ({ page }) => { + const clientName = uniqueName('Reported'); + await login(page, uniqueEmail('reports')); + + await gotoSection(page, 'Clients'); + await createClient(page, clientName); + + await gotoSection(page, 'Work Entries'); + await createWorkEntry(page, clientName, '2.5', 'Discovery'); + await createWorkEntry(page, clientName, '3.25', 'Build'); + + await gotoSection(page, 'Reports'); + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: clientName, exact: true }).click(); + + await expect(summaryCard(page, 'Total Hours')).toContainText('5.75'); + await expect(summaryCard(page, 'Total Entries')).toContainText('2'); + await expect(summaryCard(page, 'Average Hours per Entry')).toContainText('2.88'); + await expect(page.getByRole('row', { name: /Discovery/ })).toBeVisible(); + await expect(page.getByRole('row', { name: /Build/ })).toBeVisible(); + + await gotoSection(page, 'Work Entries'); + acceptNextConfirm(page); + await page.getByRole('row', { name: /Discovery/ }).getByRole('button').last().click(); + await expect(page.getByRole('row', { name: /Discovery/ })).toHaveCount(0); + + await gotoSection(page, 'Reports'); + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: clientName, exact: true }).click(); + + await expect(summaryCard(page, 'Total Hours')).toContainText('3.25'); + await expect(summaryCard(page, 'Total Entries')).toContainText('1'); + }); + + test('CSV export contains the entry with a calendar date', async ({ page }) => { + const clientName = uniqueName('Exported'); + await login(page, uniqueEmail('reports-csv')); + + await gotoSection(page, 'Clients'); + await createClient(page, clientName); + await gotoSection(page, 'Work Entries'); + await createWorkEntry(page, clientName, '6', 'Exportable work'); + + await gotoSection(page, 'Reports'); + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: clientName, exact: true }).click(); + await expect(summaryCard(page, 'Total Hours')).toContainText('6.00'); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('button:has(svg[data-testid="DescriptionIcon"])').click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/\.csv$/); + + const stream = await download.createReadStream(); + const csv = (await new Response(stream as unknown as ReadableStream).text()).trim(); + expect(csv.split('\n')[0]).toBe('Date,Hours,Description,Created At'); + expect(csv).toContain('Exportable work'); + expect(csv).toMatch(/\d{4}-\d{2}-\d{2},6/); + }); + + test('report shows an empty state for a client without entries', async ({ page }) => { + const clientName = uniqueName('Idle'); + await login(page, uniqueEmail('reports-empty')); + + await gotoSection(page, 'Clients'); + await createClient(page, clientName); + await gotoSection(page, 'Reports'); + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: clientName, exact: true }).click(); + + await expect(summaryCard(page, 'Total Hours')).toContainText('0.00'); + await expect(page.getByText('No work entries found for this client.')).toBeVisible(); + }); +}); diff --git a/e2e/tests/work-entries.spec.ts b/e2e/tests/work-entries.spec.ts new file mode 100644 index 00000000..530af13b --- /dev/null +++ b/e2e/tests/work-entries.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test'; +import { + acceptNextConfirm, + createClient, + createWorkEntry, + gotoSection, + hoursInput, + login, + uniqueEmail, + uniqueName, +} from './helpers'; + +test.describe('Work entry lifecycle', () => { + test('creates, lists, edits and deletes a work entry', async ({ page }) => { + const clientName = uniqueName('Timebox'); + await login(page, uniqueEmail('entries')); + + await gotoSection(page, 'Clients'); + await createClient(page, clientName); + + await gotoSection(page, 'Work Entries'); + await createWorkEntry(page, clientName, '4.5', 'Initial implementation'); + + const row = page.getByRole('row', { name: /Initial implementation/ }); + await expect(row).toContainText(clientName); + await expect(row).toContainText('4.5'); + + await row.getByRole('button').first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('heading', { name: 'Edit Work Entry' })).toBeVisible(); + await hoursInput(page).fill('7.25'); + await dialog.getByRole('button', { name: 'Update' }).click(); + await expect(dialog).toBeHidden(); + + const updatedRow = page.getByRole('row', { name: /Initial implementation/ }); + await expect(updatedRow).toContainText('7.25'); + await expect(page.getByRole('row', { name: /4\.5 hours/ })).toHaveCount(0); + + acceptNextConfirm(page); + await updatedRow.getByRole('button').last().click(); + + await expect(page.getByText('No work entries found. Add your first work entry to get started.')).toBeVisible(); + }); + + test('entries survive a page reload and show the correct client', async ({ page }) => { + const clientName = uniqueName('Persist'); + await login(page, uniqueEmail('entries-reload')); + + await gotoSection(page, 'Clients'); + await createClient(page, clientName); + await gotoSection(page, 'Work Entries'); + await createWorkEntry(page, clientName, '3', 'Reload check'); + + await page.reload(); + const row = page.getByRole('row', { name: /Reload check/ }); + await expect(row).toContainText(clientName); + await expect(row).toContainText('3 hours'); + }); + + test('prompts to create a client when none exist', async ({ page }) => { + await login(page, uniqueEmail('entries-empty')); + await gotoSection(page, 'Work Entries'); + + await expect(page.getByText('You need to create at least one client before adding work entries.')).toBeVisible(); + }); +});