diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..f933b8c --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,68 @@ +name: Playwright Tests + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + workflow_dispatch: + +jobs: + e2e_tests: + name: 'E2E Tests' + timeout-minutes: 60 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2 + + - name: Set up env + run: cp .env.example .env && sed -i 's/password@db/password@localhost/' .env + + - name: Cache dependencies + uses: actions/cache@v3 + id: cache-dependencies + with: + path: node_modules + key: packages-${{ hashFiles('bun.lockb') }} + + - name: Install dependencies + if: steps.cache-dependencies.outputs.cache-hit != 'true' + run: bun install --frozen-lockfile + + - name: Build the app + run: bun run build + + - name: Start Services + run: bun run dx:up + + - name: Cache playwright + id: cache-playwright + uses: actions/cache@v3 + with: + path: | + ~/.cache/ms-playwright + ${{ github.workspace }}/node_modules/playwright + key: playwright-${{ hashFiles('bun.lockb') }} + restore-keys: playwright- + + - name: Install playwright + if: steps.cache-playwright.outputs.cache-hit != 'true' + run: bunx playwright install --with-deps + + - name: Create the database + run: bun run db:migrate-dev + + - name: Seed the database + run: bun run db:seed + + - name: Run Playwright tests + run: bun run test:e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: '${{github.workspace}}/test-results/*' + retention-days: 30 diff --git a/.github/workflows/test.yml b/.github/workflows/unit-tests.yml similarity index 95% rename from .github/workflows/test.yml rename to .github/workflows/unit-tests.yml index 723509b..635b045 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/unit-tests.yml @@ -27,4 +27,4 @@ jobs: run: bun install --frozen-lockfile - name: Run tests - run: bunx vitest run + run: bun test:unit diff --git a/.gitignore b/.gitignore index 6858e22..553dc89 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ /build +/test-results/ +/playwright-report/ +/playwright/.cache/ + ### macOS ### # General .DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 294a0ec..c00abe6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ We would love your help! We want to make contributing to this project as easy an 2. Copy the `.env.example` file to `.env`. - Change the `ORIGIN` variable to `http://localhost:5173`. - Change `db` to `localhost` in the `DB_URL`. -3. Run `bun d` to install dependencies, start the database, run the migrations and run the application. +3. Run `bun setup` to install dependencies, start the database, run the migrations and run the application. 4. From now on, you can run `bun d` to run the application. ## Summary of the contribution flow @@ -88,12 +88,14 @@ Please use our issues templates that provide you with hints on what information ## Pull Requests **Please, make sure you open an issue before starting with a Pull Request, unless it's a typo or a really obvious error.** -Pull requests are the best way to propose changes to the specification. Take time to check the current working branch -for the repository you want to contribute on before working :wink: +Pull requests are the best way to propose changes to the specification. Take time to check the current working branch before working :wink: + +Ensure that your code follows the code style and that you have run the tests before submitting a pull request. If you +are adding a new feature, make sure to add tests for it. ## Conventional commits -Our repositories follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) specification. +Our repository follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) specification. Pull requests should have a title that follows the specification, otherwise, merging is blocked. If you are not familiar with the specification simply ask maintainers to modify. You can also use this cheatsheet if you want: diff --git a/bun.lockb b/bun.lockb index fab033f..22bb54e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/eslint.config.js b/eslint.config.js index bd80d28..b184683 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,7 @@ export default ts.config( globals: { ...globals.browser, ...globals.es2017, + process: true, }, }, }, diff --git a/package.json b/package.json index a436c2c..1699b87 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,20 @@ "dx": "bun i && bun run dx:up && bun run db:migrate-dev", "dx:up": "docker compose -f docker/development/compose.yml up -d --wait", "dx:down": "docker compose -f docker/development/compose.yml down", + "setup": "bun run dx && bun run db:seed && bun run dev", "lint": "prettier --check \"src/**/*.{js,ts,html,svelte,css}\" --ignore-path ./.prettierignore && eslint ./src", "format": "prettier --write \"src/**/*.{js,ts,html,svelte,css}\" --ignore-path ./.prettierignore && eslint ./src --fix", - "test": "vitest --run --passWithNoTests", - "test:watch": "vitest --watch --passWithNoTests", + "test:unit": "vitest --run --dir src --passWithNoTests", + "test:unit:watch": "vitest --watch --dir src --passWithNoTests", + "test:e2e": "start-server-and-test \"bun run preview --port 3000\" http://localhost:3000 \"bunx playwright test\"", + "pretest:e2e:dev": "bun scripts/other/confirm.js \"Are you sure you want to run e2e tests? This will reset your database (yes/no)\"", + "test:e2e:dev": "bun run db:reset && bunx playwright test && bun run db:reset", + "test:e2e:results": "bunx playwright show-report", "coverage": "vitest --run --coverage --passWithNoTests", - "db:migrate-dev": "prisma migrate dev", + "db:migrate-dev": "prisma migrate dev --skip-seed", "db:migrate-deploy": "prisma migrate deploy", + "db:reset": "prisma migrate reset --force", + "db:seed": "prisma db seed", "db:push": "prisma db push" }, "dependencies": { @@ -69,6 +76,7 @@ "@eslint/js": "^9.12.0", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.0", + "@playwright/test": "^1.49.0", "@sveltejs/adapter-auto": "^3.2.5", "@sveltejs/adapter-node": "^5.2.5", "@sveltejs/kit": "^2.7.1", @@ -96,6 +104,7 @@ "prettier-plugin-tailwindcss": "^0.6.8", "prisma": "^5.20.0", "prisma-kysely": "^1.8.0", + "start-server-and-test": "^2.0.8", "svelte": "^5.0.0", "svelte-adapter-bun": "^0.5.2", "svelte-check": "^4.0.4", @@ -104,5 +113,8 @@ "typescript-eslint": "^8.8.0", "vite": "^5.4.8", "vitest": "^2.1.4" + }, + "prisma": { + "seed": "bun ./prisma/seed" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4310463 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,76 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +dotenv.config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e', + /* Required as some tests reset the database. */ + workers: 1, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? 'github' : 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + video: 'retain-on-failure', + }, + + timeout: 30_000, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..e71fa60 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { CamelCasePlugin, Kysely, PostgresDialect } from 'kysely'; +import pg from 'pg'; + +import type { DB } from '$lib/db/schema'; + +const pool = new pg.Pool({ connectionString: process.env.DB_URL }); +const dialect = new PostgresDialect({ pool }); + +const db = new Kysely({ + dialect, + plugins: [new CamelCasePlugin()], +}); + +const main = async () => { + const files = fs.readdirSync(path.join(__dirname, './seed')); + + for (const file of files) { + const stat = fs.statSync(path.join(__dirname, './seed', file)); + + if (stat.isFile()) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require(path.join(__dirname, './seed', file)); + + if ('seedDatabase' in mod && typeof mod.seedDatabase === 'function') { + console.log(`[SEEDING]: ${file}`); + + try { + await mod.seedDatabase(db); + } catch (e) { + console.log(`[SEEDING]: Seed failed for ${file}`); + console.error(e); + } + } + } + } +}; + +main() + .then(() => { + db.destroy(); + }) + .catch((e) => { + console.error(e); + db.destroy(); + process.exit(1); + }); diff --git a/prisma/seed/flight.ts b/prisma/seed/flight.ts new file mode 100644 index 0000000..6875cc8 --- /dev/null +++ b/prisma/seed/flight.ts @@ -0,0 +1,17 @@ +import { format } from 'date-fns'; +import { Kysely } from 'kysely'; + +import { createFlightPrimitive } from '../../src/lib/db/queries'; +import type { DB } from '../../src/lib/db/schema'; + +export const seedFlight = async (db: Kysely, userId: string) => { + await createFlightPrimitive(db, { + seats: [ + { userId, seat: 'window', seatNumber: '11F', seatClass: 'economy' }, + ], + from: 'EKCH', + to: 'ESSA', + date: format(new Date(), 'yyyy-MM-dd'), + duration: 70 * 60, + }); +}; diff --git a/prisma/seed/initial-seed.ts b/prisma/seed/initial-seed.ts new file mode 100644 index 0000000..4fa5524 --- /dev/null +++ b/prisma/seed/initial-seed.ts @@ -0,0 +1,11 @@ +import { Kysely } from 'kysely'; + +import type { DB } from '../../src/lib/db/schema'; + +import { seedFlight } from './flight'; +import { seedUser } from './user'; + +export const seedDatabase = async (db: Kysely) => { + const user = await seedUser(db); + await seedFlight(db, user.id); +}; diff --git a/prisma/seed/user.ts b/prisma/seed/user.ts new file mode 100644 index 0000000..866b331 --- /dev/null +++ b/prisma/seed/user.ts @@ -0,0 +1,25 @@ +import { Kysely } from 'kysely'; +import { generateId } from 'lucia'; + +import type { DB } from '../../src/lib/db/schema'; +import { hashPassword } from '../../src/lib/server/utils/password'; + +export const SEED_USER = { + username: 'test', + password: 'password', + displayName: 'Test User', + role: 'owner', + unit: 'metric', +} as const; + +export const seedUser = async (db: Kysely) => { + return await db + .insertInto('user') + .values({ + ...SEED_USER, + id: generateId(15), + password: await hashPassword(SEED_USER.password), + }) + .returning('id') + .executeTakeFirstOrThrow(); +}; diff --git a/scripts/other/confirm.js b/scripts/other/confirm.js new file mode 100644 index 0000000..d440f25 --- /dev/null +++ b/scripts/other/confirm.js @@ -0,0 +1,20 @@ +import readline from 'readline'; + +const args = process.argv.slice(2); +const message = args[0] || 'Are you sure you want to proceed? (yes/no)'; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +rl.question(`${message}: `, (answer) => { + if (answer.toLowerCase() === 'yes') { + rl.close(); + process.exit(0); + } else { + console.log('Operation aborted.'); + rl.close(); + process.exit(1); + } +}); diff --git a/src/lib/components/NavigationDock.svelte b/src/lib/components/NavigationDock.svelte index 408b10a..570941c 100644 --- a/src/lib/components/NavigationDock.svelte +++ b/src/lib/components/NavigationDock.svelte @@ -43,6 +43,7 @@ const settingsItem = { label: 'Settings', icon: Settings, + id: 'settings-button', onClick: () => { openModalsState.settings = true; }, diff --git a/src/lib/components/dock/DockTooltipItem.svelte b/src/lib/components/dock/DockTooltipItem.svelte index 9c9ee92..68682b7 100644 --- a/src/lib/components/dock/DockTooltipItem.svelte +++ b/src/lib/components/dock/DockTooltipItem.svelte @@ -5,6 +5,7 @@ export let item: { label: string; icon: any; + id?: string; href?: string; onClick?: () => void; }; @@ -34,9 +35,11 @@ {:else} - +

{item.label}

diff --git a/src/lib/db/queries.ts b/src/lib/db/queries.ts new file mode 100644 index 0000000..2341944 --- /dev/null +++ b/src/lib/db/queries.ts @@ -0,0 +1,100 @@ +import type { Kysely } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; + +import type { DB } from './schema'; +import type { CreateFlight } from './types'; + +export const listFlightPrimitive = async (db: Kysely, userId: string) => { + return await db + .selectFrom('flight') + .selectAll('flight') + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom('seat') + .selectAll() + .whereRef('seat.flightId', '=', 'flight.id'), + ).as('seats'), + ]) + .where((eb) => + eb.exists( + eb + .selectFrom('seat') + .select('seat.id') + .whereRef('seat.flightId', '=', 'flight.id') + .where('seat.userId', '=', userId), + ), + ) + .execute(); +}; + +export const getFlightPrimitive = async (db: Kysely, id: number) => { + return await db + .selectFrom('flight') + .selectAll() + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('seat') + .selectAll() + .whereRef('seat.flightId', '=', 'flight.id'), + ).as('seats'), + ) + .where('id', '=', id) + .executeTakeFirst(); +}; + +export const createFlightPrimitive = async ( + db: Kysely, + data: CreateFlight, +) => { + await db.transaction().execute(async (trx) => { + const { seats, ...flightData } = data; + const resp = await trx + .insertInto('flight') + .values(flightData) + .returning('id') + .executeTakeFirstOrThrow(); + + const seatData = seats.map((seat) => ({ + flightId: resp.id, + userId: seat.userId, + guestName: seat.guestName, + seat: seat.seat, + seatNumber: seat.seatNumber, + seatClass: seat.seatClass, + })); + + await trx.insertInto('seat').values(seatData).executeTakeFirstOrThrow(); + }); +}; + +export const updateFlightPrimitive = async ( + db: Kysely, + id: number, + data: CreateFlight, +) => { + await db.transaction().execute(async (trx) => { + const { seats, ...flightData } = data; + await trx + .updateTable('flight') + .set(flightData) + .where('id', '=', id) + .executeTakeFirstOrThrow(); + + if (seats.length) { + await trx.deleteFrom('seat').where('flightId', '=', id).execute(); + + const seatData = seats.map((seat) => ({ + flightId: id, + userId: seat.userId, + guestName: seat.guestName, + seat: seat.seat, + seatNumber: seat.seatNumber, + seatClass: seat.seatClass, + })); + + await trx.insertInto('seat').values(seatData).executeTakeFirstOrThrow(); + } + }); +}; diff --git a/src/lib/server/utils/flight.ts b/src/lib/server/utils/flight.ts index ea55309..1c9cae0 100644 --- a/src/lib/server/utils/flight.ts +++ b/src/lib/server/utils/flight.ts @@ -1,68 +1,22 @@ -import { jsonArrayFrom } from 'kysely/helpers/postgres'; - import { db } from '$lib/db'; +import { + createFlightPrimitive, + getFlightPrimitive, + listFlightPrimitive, + updateFlightPrimitive, +} from '$lib/db/queries'; import type { CreateFlight } from '$lib/db/types'; export const listFlights = async (userId: string) => { - return await db - .selectFrom('flight') - .selectAll('flight') - .select((eb) => [ - jsonArrayFrom( - eb - .selectFrom('seat') - .selectAll() - .whereRef('seat.flightId', '=', 'flight.id'), - ).as('seats'), - ]) - .where((eb) => - eb.exists( - eb - .selectFrom('seat') - .select('seat.id') - .whereRef('seat.flightId', '=', 'flight.id') - .where('seat.userId', '=', userId), - ), - ) - .execute(); + return await listFlightPrimitive(db, userId); }; export const getFlight = async (id: number) => { - return await db - .selectFrom('flight') - .selectAll() - .select((eb) => - jsonArrayFrom( - eb - .selectFrom('seat') - .selectAll() - .whereRef('seat.flightId', '=', 'flight.id'), - ).as('seats'), - ) - .where('id', '=', id) - .executeTakeFirst(); + return await getFlightPrimitive(db, id); }; export const createFlight = async (data: CreateFlight) => { - await db.transaction().execute(async (trx) => { - const { seats, ...flightData } = data; - const resp = await trx - .insertInto('flight') - .values(flightData) - .returning('id') - .executeTakeFirstOrThrow(); - - const seatData = seats.map((seat) => ({ - flightId: resp.id, - userId: seat.userId, - guestName: seat.guestName, - seat: seat.seat, - seatNumber: seat.seatNumber, - seatClass: seat.seatClass, - })); - - await trx.insertInto('seat').values(seatData).executeTakeFirstOrThrow(); - }); + await createFlightPrimitive(db, data); }; export const deleteFlight = async (id: number) => { @@ -70,27 +24,5 @@ export const deleteFlight = async (id: number) => { }; export const updateFlight = async (id: number, data: CreateFlight) => { - await db.transaction().execute(async (trx) => { - const { seats, ...flightData } = data; - await trx - .updateTable('flight') - .set(flightData) - .where('id', '=', id) - .executeTakeFirstOrThrow(); - - if (seats.length) { - await trx.deleteFrom('seat').where('flightId', '=', id).execute(); - - const seatData = seats.map((seat) => ({ - flightId: id, - userId: seat.userId, - guestName: seat.guestName, - seat: seat.seat, - seatNumber: seat.seatNumber, - seatClass: seat.seatClass, - })); - - await trx.insertInto('seat').values(seatData).executeTakeFirstOrThrow(); - } - }); + return await updateFlightPrimitive(db, id, data); }; diff --git a/tests/e2e/fixtures/authentication.ts b/tests/e2e/fixtures/authentication.ts new file mode 100644 index 0000000..d63872e --- /dev/null +++ b/tests/e2e/fixtures/authentication.ts @@ -0,0 +1,14 @@ +import type { Page } from '@playwright/test'; + +export const signin = async ( + page: Page, + username = 'test', + password = 'password', +) => { + await page.goto('/login'); + await page.fill('input[name="username"]', username); + await page.fill('input[name="password"]', password); + await page.click('button[type="submit"]'); + + await page.waitForURL('/'); +}; diff --git a/tests/e2e/fixtures/db.ts b/tests/e2e/fixtures/db.ts new file mode 100644 index 0000000..af8e412 --- /dev/null +++ b/tests/e2e/fixtures/db.ts @@ -0,0 +1,19 @@ +import { test as base } from '@playwright/test'; +import { CamelCasePlugin, Kysely, PostgresDialect } from 'kysely'; +import pg from 'pg'; + +import type { DB } from '$lib/db/schema'; + +const pool = new pg.Pool({ connectionString: process.env.DB_URL }); +const dialect = new PostgresDialect({ pool }); + +const db = new Kysely({ + dialect, + plugins: [new CamelCasePlugin()], +}); + +export const test = base.extend<{ db: Kysely }>({ + db: async ({ page: _ }, use) => { + await use(db); + }, +}); diff --git a/tests/e2e/fixtures/url.ts b/tests/e2e/fixtures/url.ts new file mode 100644 index 0000000..915172d --- /dev/null +++ b/tests/e2e/fixtures/url.ts @@ -0,0 +1,3 @@ +export const isPathname = (pathname: string) => { + return (url: URL) => url.pathname === pathname; +}; diff --git a/tests/e2e/user/access.spec.ts b/tests/e2e/user/access.spec.ts new file mode 100644 index 0000000..ae249ba --- /dev/null +++ b/tests/e2e/user/access.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +import { signin } from '../fixtures/authentication'; +import { isPathname } from '../fixtures/url'; + +test('no unauthorized access', async ({ page }) => { + await page.goto('/'); + await page.waitForURL(isPathname('/login')); +}); + +test('can sign in', async ({ page }) => { + await signin(page); + + await page.locator('id=settings-button').click(); + const userName = await page.getByText('Test User').textContent(); + expect(userName).toBe('Test User'); +}); diff --git a/tests/e2e/user/setup.spec.ts b/tests/e2e/user/setup.spec.ts new file mode 100644 index 0000000..73367b6 --- /dev/null +++ b/tests/e2e/user/setup.spec.ts @@ -0,0 +1,22 @@ +import { SEED_USER } from '../../../prisma/seed/user'; +import { test } from '../fixtures/db'; +import { isPathname } from '../fixtures/url'; + +test('can complete set up', async ({ page, db }) => { + await db.deleteFrom('user').execute(); + + await page.goto('/'); + await page.waitForURL(/\/setup/); + + await page.fill('input[name="username"]', SEED_USER.username); + await page.fill('input[name="password"]', SEED_USER.password); + await page.fill('input[name="displayName"]', SEED_USER.displayName); + await page.click('button[type="submit"]'); + + await page.waitForURL(isPathname('/')); +}); + +test('cannot complete set up if user already exists', async ({ page }) => { + await page.goto('/setup'); + await page.waitForURL(isPathname('/login')); +}); diff --git a/vite.config.ts b/vite.config.ts index 799bb3c..d8a000e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ +import { o7Icon } from '@o7/icon/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; -import { o7Icon } from '@o7/icon/vite'; export default defineConfig({ plugins: [o7Icon(), sveltekit()],