diff --git a/.dockerignore b/.dockerignore
index 4a1be89..2e0808a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -29,6 +29,7 @@
**/minio-storage
**/vite.config.js.timestamp-*
**/vite.config.ts.timestamp-*
+tests
.output
.vercel
.netlify
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
new file mode 100644
index 0000000..0b8320e
--- /dev/null
+++ b/.github/workflows/playwright.yml
@@ -0,0 +1,64 @@
+name: Tests
+on: [push, pull_request]
+
+jobs:
+ test:
+ name: Tests
+ runs-on: ubuntu-latest
+ services:
+ # Cassandra service container
+ cassandra:
+ image: cassandra:latest
+ ports:
+ - 9042:9042
+ options: >-
+ --health-cmd="nodetool status"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+ env:
+ CASSANDRA_USER: admin
+ CASSANDRA_PASSWORD: admin
+ # Minio service container
+ minio:
+ image: docker.io/bitnami/minio
+ ports:
+ - 9000:9000
+ env:
+ MINIO_ROOT_USER: minioadmin
+ MINIO_ROOT_PASSWORD: minioadmin
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Install Playwright browsers
+ run: bunx playwright install --with-deps chromium
+
+ - name: Set up users DB
+ run: bun run migrate
+
+ - name: Setup environment
+ run: cp .env.example .env
+
+ - name: Run Playwright tests
+ run: |
+ bun run dev --host &
+ bun run test
+ env:
+ NODE_ENV: testing
+
+ - name: Upload test results
+ uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() }}
+ with:
+ name: test-results
+ path: test-results/
+ retention-days: 30
diff --git a/.gitignore b/.gitignore
index a0e84f5..2f0291d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,9 @@ Thumbs.db
!.env.example
!.env.test
+# Tests
+test-results
+
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
diff --git a/README.md b/README.md
index 6d7d7ab..753a9f6 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@ A simple chat app built with SvelteKit and Apache Cassandra
[](https://github.com/arithefirst/sv-chat/actions/workflows/prettier.yml)
[](https://github.com/arithefirst/sv-chat/actions/workflows/eslint.yml)
+[](https://github.com/arithefirst/sv-chat/actions/workflows/playwright.yml)
## 💻 Techstack
diff --git a/bun.lockb b/bun.lockb
index 8370767..87c3dab 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 2d4216c..dfc67e2 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,9 @@
"minio": "minio server minio-storage",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
- "production": "tsm ./prodServer.ts"
+ "production": "tsm ./prodServer.ts",
+ "test": "playwright test",
+ "test:head": "playwright test --headed"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
@@ -37,6 +39,7 @@
"typescript-eslint": "^8.20.0"
},
"dependencies": {
+ "@playwright/test": "^1.50.1",
"@sveltejs/adapter-node": "^5.2.12",
"@tailwindcss/typography": "^0.5.16",
"@types/better-sqlite3": "^7.6.12",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..16fc586
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,29 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: 'tests',
+ projects: [
+ {
+ name: 'setup',
+ testMatch: /setup\.ts/,
+ use: { ...devices['Desktop Chrome'] },
+ },
+
+ // Make all other tests depend on the signup,
+ // since they need user accounts to run
+
+ {
+ name: 'test',
+ use: { ...devices['Desktop Chrome'] },
+ dependencies: ['setup'],
+ testMatch: /(.+\.)?(test|spec)\.[jt]s/,
+ },
+ ],
+ retries: process.env.CI ? 1 : 0,
+ reporter: 'list',
+ workers: 1,
+ use: {
+ baseURL: 'http://localhost:5173',
+ trace: 'on-first-retry',
+ },
+});
diff --git a/src/lib/components/mainLayout.svelte b/src/lib/components/mainLayout.svelte
index d581f90..5003b06 100644
--- a/src/lib/components/mainLayout.svelte
+++ b/src/lib/components/mainLayout.svelte
@@ -18,7 +18,7 @@
-
+
@@ -28,14 +28,14 @@
-
+
-
+
diff --git a/src/lib/components/user.svelte b/src/lib/components/user.svelte
index 650e1b9..37d6559 100644
--- a/src/lib/components/user.svelte
+++ b/src/lib/components/user.svelte
@@ -12,11 +12,11 @@
-

+
-
{data.user.username}
+
{data.user.username}
diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts
index be675ba..e4b0671 100644
--- a/src/lib/server/db/index.ts
+++ b/src/lib/server/db/index.ts
@@ -1,10 +1,10 @@
import cassandra from 'cassandra-driver';
import 'dotenv/config';
-interface Messages {
+type Messages = {
messages: cassandra.types.Row[] | null;
error: Error | null;
-}
+};
function createDelay(ms: number) {
return new Promise((res) => setTimeout(res, ms));
@@ -36,8 +36,8 @@ class Db {
try {
await this.client.connect();
break;
- } catch {
- console.error(`Error communicating with DB. Retrying...`);
+ } catch (e) {
+ console.error(`Error communicating with DB (${this.clientUrl}:9042). Retrying.. ${(e as Error).message}`);
await createDelay(1000);
}
}
diff --git a/src/lib/server/db/sqlite.ts b/src/lib/server/db/sqlite.ts
index b80394a..2ab6402 100644
--- a/src/lib/server/db/sqlite.ts
+++ b/src/lib/server/db/sqlite.ts
@@ -1,9 +1,9 @@
import Database from 'better-sqlite3';
-interface Profile {
+type Profile = {
username: string;
image: string;
-}
+};
class AuthDb {
private client = new Database('./src/lib/server/db/users.db');
diff --git a/src/lib/server/storage/minio-client.ts b/src/lib/server/storage/minio-client.ts
index 83047bd..e3c3e0c 100644
--- a/src/lib/server/storage/minio-client.ts
+++ b/src/lib/server/storage/minio-client.ts
@@ -3,13 +3,13 @@ import * as Minio from 'minio';
import { Readable } from 'stream';
import { v4 } from 'uuid';
-interface ClientParams {
+type ClientParams = {
endPoint: string;
port: number;
accessKey: string;
secretKey: string;
useSSL: boolean;
-}
+};
class MinioClient {
private client: Minio.Client;
@@ -51,7 +51,11 @@ class MinioClient {
const bucket = 'profile-photos';
if (!(await this.client.bucketExists(bucket))) {
console.log(`\x1b[35m[S3]\x1b[0m Creating bucket '${bucket}', as it is required but does not exist.`);
- this.client.makeBucket(bucket);
+ try {
+ await this.client.makeBucket(bucket);
+ } catch (e) {
+ console.error((e as Error).message);
+ }
}
const objectId = `${v4()}${this.getFileExtension(mime)}`;
@@ -62,7 +66,9 @@ class MinioClient {
etag: upload.etag,
};
} catch (e) {
- console.error(`Error uploading file: ${(e as Error).message}`);
+ if ((e as Error).message !== 'Unsupported file type') {
+ console.error(`Error uploading file: ${(e as Error).message}`);
+ }
throw e;
}
}
diff --git a/src/lib/types/account.ts b/src/lib/types/account.ts
index fffbbf6..027131f 100644
--- a/src/lib/types/account.ts
+++ b/src/lib/types/account.ts
@@ -6,10 +6,10 @@ export const changePasswordSchema = z
newPassword: z
.string()
.min(8, 'New password must be at least 8 characters.')
- .regex(/(?=.*[A-Z])/gm, 'New password must contain at uppercase letter.')
- .regex(/(?=.*[a-z])/gm, 'New password must contain at lowercase letter.')
+ .regex(/(?=.*[A-Z])/gm, 'New password must contain an uppercase letter.')
+ .regex(/(?=.*[a-z])/gm, 'New password must contain a lowercase letter.')
.regex(/(?=.*\d)/gm, 'New password must contain at least one number.')
- .regex(/(?=.*\W)/gm, 'New password must contain at least one special character'),
+ .regex(/(?=.*\W)/gm, 'New password must contain at least one special character.'),
})
.refine((schema) => schema.newPassword !== 'Password123!', {
message: "You can't use the example password, silly",
@@ -25,8 +25,8 @@ export const changeUsernameSchema = z.object({
.string()
.min(3, 'Username must be at least 3 characters.')
.max(15, 'Username must be no more than 15 characters.')
- .regex(/^(?![A-Z])/gm, 'Username cannot contain uppercase letters')
- .regex(/^(?=[a-z0-9-_]+$)/gm, 'Username cannot contain special characters'),
+ .regex(/^(?![A-Z])/gm, 'Username cannot contain uppercase letters.')
+ .regex(/^(?=[a-z0-9-_]+$)/gm, 'Username cannot contain special characters.'),
});
export type ChangePasswordSchema = typeof changePasswordSchema;
diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts
index e00f13e..40deea5 100644
--- a/src/lib/types/index.ts
+++ b/src/lib/types/index.ts
@@ -1,16 +1,11 @@
-export interface TypeMessage {
+export type TypeMessage = {
message: string;
imageSrc: string;
user: string;
uid: string;
timestamp: Date;
-}
+};
-export interface TypeFullMessage {
+export type TypeFullMessage = TypeMessage & {
channel: string;
- message: string;
- imageSrc: string;
- user: string;
- uid: string;
- timestamp: Date;
-}
+};
diff --git a/src/lib/types/misc.ts b/src/lib/types/misc.ts
index e8bc2c8..a7b4f3d 100644
--- a/src/lib/types/misc.ts
+++ b/src/lib/types/misc.ts
@@ -1,7 +1,11 @@
import { z } from 'zod';
export const newChannelSchema = z.object({
- channelName: z.string().min(1, 'Channel name is required').max(24, 'Channel name cannot be longer than 24 characters.'),
+ channelName: z
+ .string()
+ .min(1, 'Channel name is required')
+ .max(24, 'Channel name cannot be longer than 24 characters.')
+ .refine((value) => !/^\d/.test(value), 'Channel name cannot start with a number.'),
});
export type NewChannelSchema = typeof newChannelSchema;
diff --git a/src/lib/types/signup.ts b/src/lib/types/signup.ts
index f343afc..7f198e0 100644
--- a/src/lib/types/signup.ts
+++ b/src/lib/types/signup.ts
@@ -7,15 +7,15 @@ export const signupSchema = z
.string()
.min(3, 'Username must be at least 3 characters.')
.max(15, 'Username must be no more than 15 characters.')
- .regex(/^(?![A-Z])/gm, 'Username cannot contain uppercase letters')
- .regex(/^(?=[a-z0-9-_]+$)/gm, 'Username cannot contain special characters'),
+ .regex(/^(?![A-Z])/gm, 'Username cannot contain uppercase letters.')
+ .regex(/^(?=[a-z0-9-_]+$)/gm, 'Username cannot contain special characters.'),
password: z
.string()
.min(8, 'Password must be at least 8 characters.')
- .regex(/(?=.*[A-Z])/gm, 'Password must contain at uppercase letter.')
- .regex(/(?=.*[a-z])/gm, 'Password must contain at lowercase letter.')
+ .regex(/(?=.*[A-Z])/gm, 'Password must contain an uppercase letter.')
+ .regex(/(?=.*[a-z])/gm, 'Password must contain a lowercase letter.')
.regex(/(?=.*\d)/gm, 'Password must contain at least one number.')
- .regex(/(?=.*\W)/gm, 'Password must contain at least one special character'),
+ .regex(/(?=.*\W)/gm, 'Password must contain at least one special character.'),
verify: z.string().nonempty('Passwords do not match.'),
})
.refine((schema) => schema.password !== 'Password123!', {
diff --git a/src/routes/(main)/+layout.server.ts b/src/routes/(main)/+layout.server.ts
index 4538e8d..93a6173 100644
--- a/src/routes/(main)/+layout.server.ts
+++ b/src/routes/(main)/+layout.server.ts
@@ -6,10 +6,10 @@ import { redirect } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
-interface Profile {
+type Profile = {
username: string;
image: string;
-}
+};
export async function load({ request }) {
const session = await auth.api.getSession({
diff --git a/src/routes/(main)/channel/[channel]/+page.server.ts b/src/routes/(main)/channel/[channel]/+page.server.ts
index da5df1f..1be7eaa 100644
--- a/src/routes/(main)/channel/[channel]/+page.server.ts
+++ b/src/routes/(main)/channel/[channel]/+page.server.ts
@@ -4,11 +4,11 @@ import type { TypeMessage } from '$lib/types';
import { error, redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/db/auth';
-interface ChannelLoad {
+type ChannelLoad = {
messages: TypeMessage[];
currentUserID: string;
currentUserName: string;
-}
+};
export async function load({ params, request }): Promise {
const session = await auth.api.getSession({
diff --git a/src/routes/(server)/api/checkauth/+server.ts b/src/routes/(server)/api/checkauth/+server.ts
new file mode 100644
index 0000000..ddef114
--- /dev/null
+++ b/src/routes/(server)/api/checkauth/+server.ts
@@ -0,0 +1,14 @@
+import { auth } from '$lib/server/db/auth';
+import { json } from '@sveltejs/kit';
+
+export const GET = async ({ request }) => {
+ const session = await auth.api.getSession({
+ headers: request.headers,
+ });
+
+ if (session) {
+ return json({ status: 200 });
+ } else {
+ return json({ status: 401 }, { status: 401 });
+ }
+};
diff --git a/src/routes/signup/+page.server.ts b/src/routes/signup/+page.server.ts
index bcb1826..d763663 100644
--- a/src/routes/signup/+page.server.ts
+++ b/src/routes/signup/+page.server.ts
@@ -61,6 +61,6 @@ export const actions = {
return setError(form, 'verify', errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1));
}
- return message(form, 'Successfuly signed in.');
+ return message(form, 'Successfuly signed up.');
},
} satisfies Actions;
diff --git a/static/freakybear.jpg b/static/freakybear.jpg
new file mode 100644
index 0000000..93ba569
Binary files /dev/null and b/static/freakybear.jpg differ
diff --git a/tests/createChannel.spec.ts b/tests/createChannel.spec.ts
new file mode 100644
index 0000000..d8b0fc1
--- /dev/null
+++ b/tests/createChannel.spec.ts
@@ -0,0 +1,65 @@
+import { test, expect, type Page } from '@playwright/test';
+import { login } from './utils';
+import { v4 as uuidv4 } from 'uuid';
+
+function generate15CharUUID() {
+ // Second regex prevents UUID from starting with a number
+ return uuidv4().replace(/-/g, '').replace(/^\d*/g, '').substring(0, 15);
+}
+
+async function tryCreateChannel(page: Page, channelName: string) {
+ await page.waitForTimeout(1500);
+ await page.getByRole('button', { name: 'Create Channel' }).click();
+ await page.getByRole('textbox', { name: 'Channel Name' }).fill(channelName);
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+}
+
+test.describe('Create Channel', () => {
+ let page: Page;
+ test.beforeEach(async ({ browser }) => {
+ page = await browser.newPage();
+
+ // Login and navigate
+ await login(page);
+ await page.goto('/channel/general', { timeout: 30000, waitUntil: 'domcontentloaded' });
+ });
+
+ test('successfully create new channel', async ({ request }) => {
+ const uuid: string = generate15CharUUID();
+
+ // Try to create new channel
+ await tryCreateChannel(page, uuid);
+
+ // Check if channel exists
+ const res = await request.get(`/channel/${uuid}`);
+ expect(res.status()).toEqual(200);
+ });
+
+ test('should not allow channel names > 25 characters', async () => {
+ // Try to create new channel
+ await tryCreateChannel(page, 'thisisatwentyfivelongstring;');
+
+ const error = page.getByText('Channel name cannot be longer than 24 characters.');
+ await expect(error).toBeVisible();
+ });
+
+ test('should not allow channel names to start with a number', async () => {
+ // Try to create new channel
+ await tryCreateChannel(page, '00-test');
+
+ const error = page.getByText('Channel name cannot start with a number.');
+ await expect(error).toBeVisible();
+ });
+
+ test('should not duplicate channel names', async () => {
+ const uuid: string = generate15CharUUID();
+
+ // Try to create new channel
+ await tryCreateChannel(page, uuid);
+ await page.reload();
+ await tryCreateChannel(page, uuid);
+
+ const error = page.getByText('Channel already exists.');
+ await expect(error).toBeVisible();
+ });
+});
diff --git a/tests/message.spec.ts b/tests/message.spec.ts
new file mode 100644
index 0000000..82a0799
--- /dev/null
+++ b/tests/message.spec.ts
@@ -0,0 +1,54 @@
+import { test, expect, type Page, type Locator } from '@playwright/test';
+import { v4 as uuidv4 } from 'uuid';
+import { login } from './utils';
+
+test.describe('Messages', () => {
+ let page: Page;
+ let textBox: Locator;
+
+ test.beforeEach(async ({ browser }) => {
+ page = await browser.newPage();
+ await login(page);
+ await page.goto('/channel/general', { timeout: 30000, waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1500);
+
+ textBox = page.getByRole('textbox', { name: 'Type Here' });
+ });
+
+ test('should send and receive messages', async () => {
+ // Send a test message
+ const testMessage = `${uuidv4()}-${uuidv4()}`;
+ await textBox.fill(testMessage);
+ await textBox.press('Enter');
+
+ // Check if message appears in the chat
+ await expect(page.getByText(testMessage)).toBeVisible({ timeout: 5000 });
+ });
+
+ test('should show error for messages > 2000 characters', async () => {
+ // Create a message that exceeds 2000 characters
+ const longMessage = 'a'.repeat(2001);
+ await textBox.fill(longMessage);
+ await textBox.press('Enter');
+
+ // Message dialog should be visible with warning
+ await expect(page.getByText('This message exceeds the maximum character limit')).toBeVisible({ timeout: 5000 });
+ });
+
+ test('messages should persist after page reload', async () => {
+ // Send a unique message
+ const uniqueMessage = `${uuidv4()}-${uuidv4()}`;
+ await textBox.fill(uniqueMessage);
+ await textBox.press('Enter');
+
+ // Wait for message to appear
+ await expect(page.getByText(uniqueMessage)).toBeVisible({ timeout: 5000 });
+
+ // Reload the page
+ await page.reload();
+ await page.waitForLoadState('domcontentloaded');
+
+ // Message should still be visible
+ await expect(page.getByText(uniqueMessage)).toBeVisible({ timeout: 5000 });
+ });
+});
diff --git a/tests/profileImage.spec.ts b/tests/profileImage.spec.ts
new file mode 100644
index 0000000..af46120
--- /dev/null
+++ b/tests/profileImage.spec.ts
@@ -0,0 +1,62 @@
+import { test, expect, type Page, type Locator } from '@playwright/test';
+import { login } from './utils';
+
+async function getImgSrc(image: Locator) {
+ return await image.getAttribute('src');
+}
+
+test.describe('Profile Photo Update', () => {
+ let page: Page;
+ let fileInput: Locator;
+ let profileImage: Locator;
+ let submitButton: Locator;
+
+ test.beforeEach(async ({ browser }) => {
+ page = await browser.newPage();
+
+ // Login and navigate
+ await login(page);
+ await page.goto('/account', { timeout: 30000, waitUntil: 'domcontentloaded' });
+
+ // Initialize locators
+ submitButton = page.getByRole('button', { name: 'Update Profile Photo' });
+ profileImage = page.locator('img#userimage');
+ fileInput = page.locator('input[type="file"]');
+ });
+
+ test('successfully update profile image', async () => {
+ await page.waitForTimeout(1000);
+
+ // Get the inital image src
+ const initalSrc = await getImgSrc(profileImage);
+
+ // Upload the new image
+ await fileInput.setInputFiles(['./static/freakybear.jpg']);
+ await submitButton.click();
+
+ // Wait for upload to complete
+ const response = await page.waitForResponse((response) => response.request().method() === 'POST', { timeout: 30000 });
+ expect(response.status()).toBe(200);
+
+ // Make sure the src is not the same as the original
+ expect(await getImgSrc(profileImage)).not.toEqual(initalSrc);
+ });
+
+ test("shouldn't accept non-images", async () => {
+ await page.waitForTimeout(1000);
+
+ // Get the inital image src
+ const initalSrc = await getImgSrc(profileImage);
+
+ // Upload the new image
+ await fileInput.setInputFiles(['./README.md']);
+ await submitButton.click();
+
+ // Wait for upload to complete
+ const response = await page.waitForResponse((response) => response.request().method() === 'POST', { timeout: 30000 });
+ expect(response.status()).toBe(500);
+
+ // Make sure the src is the same as the original
+ expect(await getImgSrc(profileImage)).toEqual(initalSrc);
+ });
+});
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 0000000..14c70d6
--- /dev/null
+++ b/tests/setup.ts
@@ -0,0 +1,10 @@
+import { test } from '@playwright/test';
+import { signup, dupeSignup } from './utils';
+
+test('Create playwright user', async ({ page }) => {
+ await signup(page);
+});
+
+test('Create duplicate-detector user', async ({ page }) => {
+ await dupeSignup(page);
+});
diff --git a/tests/signout.spec.ts b/tests/signout.spec.ts
new file mode 100644
index 0000000..d77ba26
--- /dev/null
+++ b/tests/signout.spec.ts
@@ -0,0 +1,39 @@
+import { test, expect, type Page, type Locator } from '@playwright/test';
+import { login } from './utils';
+
+test.describe('Sign Out Button', () => {
+ let page: Page;
+ let button: Locator;
+
+ test.beforeEach(async ({ browser }) => {
+ page = await browser.newPage();
+
+ // Login and navigate
+ await login(page);
+ await page.goto('/account', { timeout: 30000, waitUntil: 'domcontentloaded' });
+ // Initialize locators
+ button = page.getByRole('button', { name: 'Sign Out' });
+ });
+
+ test('sign out button signs user out', async ({ request }) => {
+ // Get cookies from the browser context
+ const cookies = await page.context().cookies();
+ const cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
+
+ const initalfetch = await request.get('/api/checkauth', {
+ headers: {
+ Cookie: cookieHeader,
+ },
+ });
+ expect(initalfetch.status()).toEqual(200);
+
+ await button.click();
+
+ const finalfetch = await request.get('/api/checkauth', {
+ headers: {
+ Cookie: cookieHeader,
+ },
+ });
+ expect(finalfetch.status()).toEqual(401);
+ });
+});
diff --git a/tests/updatePassword.spec.ts b/tests/updatePassword.spec.ts
new file mode 100644
index 0000000..5d0e0ca
--- /dev/null
+++ b/tests/updatePassword.spec.ts
@@ -0,0 +1,125 @@
+import { test, expect, type Page, type Locator } from '@playwright/test';
+import { login } from './utils';
+
+async function expectError(message: string, page: Page) {
+ const errorMessageLocator = page.locator(`.text-sm.text-red-500:has-text("${message}")`);
+ await expect(errorMessageLocator).toBeVisible();
+}
+
+test.describe('Password Update Form', () => {
+ let page: Page;
+ let currentPasswordInput: Locator;
+ let newPasswordInput: Locator;
+ let submitButton: Locator;
+ const currentPassword = 'Password1234!';
+
+ test.beforeEach(async ({ browser }) => {
+ page = await browser.newPage();
+
+ // Login and navigate
+ await login(page);
+ await page.goto('/account', { timeout: 30000, waitUntil: 'domcontentloaded' });
+
+ // Initialize locators
+ currentPasswordInput = page.locator('input#currentPassword');
+ newPasswordInput = page.locator('input#newPassword');
+ submitButton = page.getByRole('button', { name: 'Update Password' });
+ });
+
+ // Test that passwords can't be the same
+ test('show not allow same password', async () => {
+ await currentPasswordInput.fill(currentPassword);
+ await newPasswordInput.fill(currentPassword);
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('New password cannot be the same as old password.', page);
+ });
+
+ // Test invalid current password
+ test('should show error for invalid current password', async () => {
+ await currentPasswordInput.fill('wrongPassword');
+ await newPasswordInput.fill('newPassword123!');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('Invalid password', page);
+ });
+
+ // Test empty fields validation
+ test('should show validation errors when fields are empty', async () => {
+ // Leave fields empty and try to submit
+ await submitButton.click();
+
+ // Check for error messages on both fields
+ await expectError('Password must not be empty.', page);
+ });
+
+ // Test validation error for missing uppercase letter
+ test('should show validation error for password without uppercase letter', async () => {
+ await currentPasswordInput.fill(currentPassword);
+ await newPasswordInput.fill('password123!');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('New password must contain an uppercase letter.', page);
+ });
+
+ // Test validation error for missing lowercase letter
+ test('should show validation error for password without lowercase letter', async () => {
+ await currentPasswordInput.fill(currentPassword);
+ await newPasswordInput.fill('PASSWORD123!');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('New password must contain a lowercase letter.', page);
+ });
+
+ // Test validation error for missing number
+ test('should show validation error for password without number', async () => {
+ await currentPasswordInput.fill(currentPassword);
+ await newPasswordInput.fill('Password!!!');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('New password must contain at least one number.', page);
+ });
+
+ // Test validation error for missing special character
+ test('should show validation error for password without special character', async () => {
+ await currentPasswordInput.fill(currentPassword);
+ await newPasswordInput.fill('Password123');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('New password must contain at least one special character.', page);
+ });
+
+ // Test validation error for using example password
+ test('should show validation error for using example password', async () => {
+ await currentPasswordInput.fill(currentPassword);
+ await newPasswordInput.fill('Password123!');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError("You can't use the example password, silly", page);
+ });
+
+ // Test update functionality
+ test('should successfully update user password', async () => {
+ await currentPasswordInput.fill(currentPassword);
+ await newPasswordInput.fill('newPassword123!');
+ await submitButton.click();
+
+ await page.waitForTimeout(1000);
+
+ // Undo password change so other tests still pass
+ await currentPasswordInput.fill('newPassword123!');
+ await newPasswordInput.fill(currentPassword);
+ await submitButton.click();
+
+ // Look for the success message
+ const successMessageLocator = page.locator('p.text-sm.text-green-500:has-text("Password updated.")');
+ await expect(successMessageLocator).toBeVisible();
+ });
+});
diff --git a/tests/updateUsername.spec.ts b/tests/updateUsername.spec.ts
new file mode 100644
index 0000000..0d37a81
--- /dev/null
+++ b/tests/updateUsername.spec.ts
@@ -0,0 +1,98 @@
+import { test, expect, type Page, type Locator } from '@playwright/test';
+import { login } from './utils';
+
+async function expectError(message: string, page: Page) {
+ const errorMessageLocator = page.locator(`.text-sm.text-red-500:has-text("${message}")`);
+ await expect(errorMessageLocator).toBeVisible();
+}
+
+test.describe('Username Update Form', () => {
+ let page: Page;
+ let usernameInput: Locator;
+ let submitButton: Locator;
+ let currentUsernameElement: Locator;
+ const newUsername: string = 'testuser' + Math.floor(Math.random() * 10000);
+
+ test.beforeEach(async ({ browser }) => {
+ page = await browser.newPage();
+
+ // Login and navigate
+ await login(page);
+ await page.goto('/account', { timeout: 30000, waitUntil: 'domcontentloaded' });
+
+ // Initialize locators
+ usernameInput = page.locator('input#username');
+ submitButton = page.getByRole('button', { name: 'Update Username' });
+ currentUsernameElement = page.locator('#currentuser-username');
+ });
+
+ // Test that the username will change
+ test('should successfully update the username', async () => {
+ await usernameInput.fill(newUsername);
+ await submitButton.click();
+
+ // Check for success message
+ const successMessageLocator = page.getByText('Username updated.');
+ await expect(successMessageLocator).toBeVisible();
+
+ // Verify the username displayed in the UI has been updated
+ const updatedUsername: string = (await currentUsernameElement.textContent()) || '';
+ expect(updatedUsername).toBe(newUsername);
+ });
+
+ // Test that new and old username can't be the same
+ test('should not allow same username', async () => {
+ const currentUsername = await currentUsernameElement.textContent();
+ expect(currentUsername).toBeTruthy();
+
+ await usernameInput.fill(currentUsername!);
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('New username cannot be the same as old username.', page);
+ });
+
+ // Test non-duplicate usernames
+ test('should not allow duplicate usernames', async () => {
+ await usernameInput.fill('existing_user');
+ await submitButton.click();
+
+ await expectError('Username taken.', page);
+ });
+
+ // Test validation error for username less than 3 characters
+ test('should show validation error for username less than 3 characters', async () => {
+ await usernameInput.fill('ab');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('Username must be at least 3 characters.', page);
+ });
+
+ // Test validation error for username more than 15 characters
+ test('should show validation error for username more than 15 characters', async () => {
+ await usernameInput.fill('abcdefghijklmnopq');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('Username must be no more than 15 characters.', page);
+ });
+
+ // Test validation error for username with uppercase letters
+ test('should show validation error for username with uppercase letters', async () => {
+ await usernameInput.fill('Username');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('Username cannot contain uppercase letters.', page);
+ });
+
+ // Test validation error for username with special characters
+ test('should show validation error for username with special characters', async () => {
+ await usernameInput.fill('user@name');
+ await submitButton.click();
+
+ // Check for error message
+ await expectError('Username cannot contain special characters.', page);
+ });
+});
diff --git a/tests/utils.ts b/tests/utils.ts
new file mode 100644
index 0000000..e489621
--- /dev/null
+++ b/tests/utils.ts
@@ -0,0 +1,27 @@
+import type { Page } from '@playwright/test';
+
+export async function login(page: Page): Promise {
+ await page.goto('/login');
+ await page.waitForLoadState('domcontentloaded');
+ await page.fill('#email', 'playwright@playwright.com');
+ await page.fill('#password', 'Password1234!');
+ await page.click('button[type="submit"]');
+}
+
+async function signupTemplate(page: Page, email: string, username: string): Promise {
+ await page.goto('/signup');
+ await page.waitForLoadState('domcontentloaded');
+ await page.fill('#username', username);
+ await page.fill('#email', email);
+ await page.fill('#password', 'Password1234!');
+ await page.fill('#verify', 'Password1234!');
+ await page.click('button[type="submit"]');
+}
+
+export async function dupeSignup(page: Page): Promise {
+ await signupTemplate(page, 'playwright2@playwright.com', 'existing_user');
+}
+
+export async function signup(page: Page): Promise {
+ await signupTemplate(page, 'playwright@playwright.com', 'playwrightuser');
+}