Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/database/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
13 changes: 11 additions & 2 deletions backend/src/routes/workEntries.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@

// 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

Check warning on line 27 in backend/src/server.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=Cognition-Partner-Workshops_app_timesheet&issues=AZ_xAmL9SlXfjNXnCia7&open=AZ_xAmL9SlXfjNXnCia7&pullRequest=917
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100 // limit each IP to this many requests per window

Check warning on line 28 in backend/src/server.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=Cognition-Partner-Workshops_app_timesheet&issues=AZ_xAmL9SlXfjNXnCia8&open=AZ_xAmL9SlXfjNXnCia8&pullRequest=917
});
app.use(limiter);

Expand Down
5 changes: 5 additions & 0 deletions e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
test-results/
playwright-report/
blob-report/
recordings/
22 changes: 22 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 79 additions & 0 deletions e2e/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
45 changes: 45 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
],
});
33 changes: 33 additions & 0 deletions e2e/tests/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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$/);
});
});
52 changes: 52 additions & 0 deletions e2e/tests/clients.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading