Add backend unit/integration tests, Playwright e2e tests, README, cookie Secure fix
SonarQube Analysis / sonarqube (push) Successful in 4m20s
SonarQube Analysis / sonarqube (push) Successful in 4m20s
This commit is contained in:
@@ -5,3 +5,7 @@ backend/dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
uploads/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Redefined Designs
|
||||
|
||||
One-of-a-kind item storefront. React + TypeScript + antd frontend (Vite), Express + TypeScript backend, Postgres, PayPal checkout, customer accounts with GDPR-style consent handling, and an authentik-gated admin panel.
|
||||
|
||||
## Project layout
|
||||
|
||||
backend/ Express + TypeScript API, Postgres access, PayPal integration
|
||||
frontend/ React + TypeScript + antd storefront and admin UI (Vite)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- Docker (for a local, disposable Postgres instance)
|
||||
- npm
|
||||
|
||||
## Clone
|
||||
|
||||
```bash
|
||||
git clone https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs.git
|
||||
cd redefined-designs
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
### 1. Start a local Postgres instance
|
||||
|
||||
The backend needs Postgres to talk to during local development. `backend/docker-compose.test.yml` spins up a throwaway, tmpfs-backed instance — no data persists between restarts, which is fine for local dev and tests.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run db:test:up
|
||||
```
|
||||
|
||||
This starts Postgres on `localhost:55432`, database `redefined_test`.
|
||||
|
||||
### 2. Load the schema
|
||||
|
||||
```bash
|
||||
docker exec -i redefined-designs-test-db psql -U redefined_test -d redefined_test < init.sql
|
||||
```
|
||||
|
||||
### 3. Configure environment variables
|
||||
|
||||
```bash
|
||||
export PGHOST=localhost
|
||||
export PGPORT=55432
|
||||
export PGUSER=redefined_test
|
||||
export PGPASSWORD=redefined_test
|
||||
export PGDATABASE=redefined_test
|
||||
export PORT=3000
|
||||
export DEMO_MODE=true
|
||||
export UPLOADS_DIR=/tmp/redefined-uploads
|
||||
mkdir -p /tmp/redefined-uploads
|
||||
```
|
||||
|
||||
`DEMO_MODE=true` enables a "Buy Now (Demo)" button on the storefront that completes a purchase without needing real PayPal credentials — useful for local development and for the Playwright tests below. To exercise real PayPal checkout locally, also set `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, and `PAYPAL_ENV=sandbox`.
|
||||
|
||||
### 4. Run the backend
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Runs on `http://localhost:3000` with hot reload.
|
||||
|
||||
### 5. Run the frontend
|
||||
|
||||
```bash
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Runs on `http://localhost:5173` and proxies `/api`, `/uploads`, `/webhooks` to the backend on port 3000.
|
||||
|
||||
### 6. Open it
|
||||
|
||||
- Storefront: http://localhost:5173
|
||||
- Admin: http://localhost:5173/admin
|
||||
|
||||
Note: locally, `/admin` is directly reachable with no login gate — the authentik SSO protection only exists in the deployed environment (Nginx Proxy Manager + authentik forward-auth), not in local dev.
|
||||
|
||||
## Running tests
|
||||
|
||||
### Backend unit tests
|
||||
|
||||
No database required.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Backend integration tests
|
||||
|
||||
Exercises the real Express app against a real (disposable) Postgres instance via `supertest`.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run db:test:up
|
||||
npm run test:integration
|
||||
npm run db:test:down # when finished
|
||||
```
|
||||
|
||||
### Frontend Playwright e2e tests
|
||||
|
||||
Needs the backend running against a database with the schema loaded (steps 1–4 above), since these tests drive real registration/login/purchase flows through a live API.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npx playwright install --with-deps # first time only — installs browser binaries
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## CI
|
||||
|
||||
Gitea Actions runs a SonarQube static analysis scan on every push to `main` and on pull requests — see `.gitea/workflows/sonarqube.yml`. It currently runs TypeScript build checks for both `backend` and `frontend`; wiring the Jest/Playwright suites into that same workflow is a natural next step once a CI-side Postgres service is added to the workflow definition.
|
||||
|
||||
## Production deployment
|
||||
|
||||
Production runs as a single Docker image (multi-stage build — the frontend is built to static files and served directly by the backend), deployed via Portainer behind Nginx Proxy Manager, with authentik forward-auth gating `/admin`. That infrastructure is homelab-specific and documented separately outside this repo.
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
TEST_PGHOST=localhost
|
||||
TEST_PGPORT=55432
|
||||
TEST_PGUSER=redefined_test
|
||||
TEST_PGPASSWORD=redefined_test
|
||||
TEST_PGDATABASE=redefined_test
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
redefined-designs-test-db:
|
||||
image: postgres:16
|
||||
container_name: redefined-designs-test-db
|
||||
environment:
|
||||
- POSTGRES_USER=redefined_test
|
||||
- POSTGRES_PASSWORD=redefined_test
|
||||
- POSTGRES_DB=redefined_test
|
||||
ports:
|
||||
- "55432:5432"
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/tests/integration/**/*.test.ts'],
|
||||
setupFiles: ['<rootDir>/tests/integration/setup/env.setup.ts'],
|
||||
globalSetup: '<rootDir>/tests/integration/setup/globalSetup.ts',
|
||||
testTimeout: 20000
|
||||
};
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/tests/unit/**/*.test.ts']
|
||||
};
|
||||
+5962
File diff suppressed because it is too large
Load Diff
+14
-2
@@ -5,7 +5,13 @@
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js"
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"test": "npm run test:unit",
|
||||
"test:unit": "jest -c jest.unit.config.js",
|
||||
"test:integration": "jest -c jest.integration.config.js --runInBand",
|
||||
"db:test:up": "docker compose -f docker-compose.test.yml up -d",
|
||||
"db:test:down": "docker compose -f docker-compose.test.yml down -v"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
@@ -17,12 +23,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.4",
|
||||
"tsx": "^4.16.5",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.15",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/nodemailer": "^6.4.15"
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"supertest": "^7.0.0",
|
||||
"@types/supertest": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'path';
|
||||
import itemsRouter from './routes/items';
|
||||
import { router as paypalRouter, webhookRouter as paypalWebhookRouter } from './routes/paypal';
|
||||
import adminRouter from './routes/admin';
|
||||
import adminCustomersRouter from './routes/adminCustomers';
|
||||
import demoRouter from './routes/demo';
|
||||
import customersRouter from './routes/customers';
|
||||
import publicRouter from './routes/public';
|
||||
import { attachCustomer } from './middleware/customerAuth';
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use('/webhooks/paypal', express.json(), paypalWebhookRouter);
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(attachCustomer);
|
||||
app.use('/uploads', express.static(process.env.UPLOADS_DIR || '/app/uploads'));
|
||||
|
||||
app.get('/api/config', (_req, res) => {
|
||||
const clientId = process.env.PAYPAL_CLIENT_ID;
|
||||
const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID';
|
||||
res.json({
|
||||
paypalClientId: isPlaceholder ? null : clientId,
|
||||
demoMode: process.env.DEMO_MODE !== 'false',
|
||||
currency: process.env.SITE_CURRENCY || 'USD'
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/items', itemsRouter);
|
||||
app.use('/api/checkout/paypal', paypalRouter);
|
||||
app.use('/api/checkout/demo', demoRouter);
|
||||
app.use('/api/admin/customers', adminCustomersRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use('/api/customers', customersRouter);
|
||||
app.use('/', publicRouter);
|
||||
|
||||
// Skip serving the built frontend during tests — there's no /public dir yet
|
||||
// at that point, and tests only care about the API surface.
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const staticDir = path.join(__dirname, '..', 'public');
|
||||
app.use(express.static(staticDir));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(staticDir, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
@@ -5,8 +5,9 @@ import { pool } from '../db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';
|
||||
const storage = multer.diskStorage({
|
||||
destination: '/app/uploads',
|
||||
destination: UPLOADS_DIR,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}${ext}`);
|
||||
|
||||
@@ -4,17 +4,16 @@ import crypto from 'node:crypto';
|
||||
import { pool } from '../db';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { sendMail } from '../mailer';
|
||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const SESSION_DAYS = 30;
|
||||
const MARKETING_CONSENT_TEXT =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
|
||||
function setSessionCookie(res: Response, token: string) {
|
||||
res.cookie('rd_session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_DAYS * 24 * 60 * 60 * 1000
|
||||
});
|
||||
@@ -43,7 +42,7 @@ function publicCustomer(c: any) {
|
||||
|
||||
router.post('/register', async (req: Request, res: Response) => {
|
||||
const { email, password, name, marketingConsent } = req.body;
|
||||
if (!email || !password || String(password).length < 8) {
|
||||
if (!email || !isValidEmail(String(email)) || !password || String(password).length < 8) {
|
||||
return res.status(400).json({ error: 'valid email and password (min 8 chars) required' });
|
||||
}
|
||||
const normalizedEmail = String(email).toLowerCase().trim();
|
||||
@@ -143,7 +142,6 @@ router.post('/change-password', requireCustomer, async (req: Request, res: Respo
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Explicit, revocable marketing consent (GDPR Art. 7 / ePrivacy)
|
||||
router.post('/me/consent', requireCustomer, async (req: Request, res: Response) => {
|
||||
const consent = !!req.body.marketingConsent;
|
||||
await pool.query(
|
||||
@@ -163,7 +161,6 @@ router.get('/me/orders', requireCustomer, async (req: Request, res: Response) =>
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// GDPR right to access / data portability
|
||||
router.get('/me/export', requireCustomer, async (req: Request, res: Response) => {
|
||||
const { rows: customerRows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const { rows: orderRows } = await pool.query(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]);
|
||||
@@ -175,7 +172,6 @@ router.get('/me/export', requireCustomer, async (req: Request, res: Response) =>
|
||||
});
|
||||
});
|
||||
|
||||
// GDPR right to erasure — order records kept for accounting but stripped of the customer link
|
||||
router.delete('/me', requireCustomer, async (req: Request, res: Response) => {
|
||||
await pool.query(`UPDATE orders SET customer_id = NULL WHERE customer_id = $1`, [req.customerId]);
|
||||
await pool.query(`DELETE FROM customers WHERE id = $1`, [req.customerId]);
|
||||
|
||||
+1
-44
@@ -1,48 +1,5 @@
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'path';
|
||||
import app from './app';
|
||||
import { pool } from './db';
|
||||
import itemsRouter from './routes/items';
|
||||
import { router as paypalRouter, webhookRouter as paypalWebhookRouter } from './routes/paypal';
|
||||
import adminRouter from './routes/admin';
|
||||
import adminCustomersRouter from './routes/adminCustomers';
|
||||
import demoRouter from './routes/demo';
|
||||
import customersRouter from './routes/customers';
|
||||
import publicRouter from './routes/public';
|
||||
import { attachCustomer } from './middleware/customerAuth';
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use('/webhooks/paypal', express.json(), paypalWebhookRouter);
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(attachCustomer);
|
||||
app.use('/uploads', express.static('/app/uploads'));
|
||||
|
||||
app.get('/api/config', (_req, res) => {
|
||||
const clientId = process.env.PAYPAL_CLIENT_ID;
|
||||
const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID';
|
||||
res.json({
|
||||
paypalClientId: isPlaceholder ? null : clientId,
|
||||
demoMode: process.env.DEMO_MODE !== 'false',
|
||||
currency: process.env.SITE_CURRENCY || 'USD'
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/items', itemsRouter);
|
||||
app.use('/api/checkout/paypal', paypalRouter);
|
||||
app.use('/api/checkout/demo', demoRouter);
|
||||
app.use('/api/admin/customers', adminCustomersRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use('/api/customers', customersRouter);
|
||||
app.use('/', publicRouter);
|
||||
|
||||
const staticDir = path.join(__dirname, '..', 'public');
|
||||
app.use(express.static(staticDir));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(staticDir, 'index.html'));
|
||||
});
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
export function toCents(price: string | number): number {
|
||||
const n = typeof price === 'string' ? parseFloat(price) : price;
|
||||
if (Number.isNaN(n) || n < 0) {
|
||||
throw new Error('invalid price');
|
||||
}
|
||||
return Math.round(n * 100);
|
||||
}
|
||||
|
||||
export function formatPrice(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return EMAIL_RE.test(email.trim());
|
||||
}
|
||||
|
||||
export const MARKETING_CONSENT_TEXT =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import { pool } from '../../src/db';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
describe('POST /api/customers/register', () => {
|
||||
it('creates an account with marketing consent unchecked by default', async () => {
|
||||
const res = await request(app).post('/api/customers/register').send({
|
||||
email: 'jane@example.com',
|
||||
password: 'supersecret123',
|
||||
name: 'Jane'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.email).toBe('jane@example.com');
|
||||
expect(res.body.marketing_consent).toBe(false);
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
});
|
||||
|
||||
it('respects an explicit marketing opt-in', async () => {
|
||||
const res = await request(app).post('/api/customers/register').send({
|
||||
email: 'opt-in@example.com',
|
||||
password: 'supersecret123',
|
||||
marketingConsent: true
|
||||
});
|
||||
expect(res.body.marketing_consent).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a duplicate email', async () => {
|
||||
await request(app).post('/api/customers/register').send({
|
||||
email: 'dupe@example.com',
|
||||
password: 'supersecret123'
|
||||
});
|
||||
const res = await request(app).post('/api/customers/register').send({
|
||||
email: 'dupe@example.com',
|
||||
password: 'anotherpassword'
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('rejects a password under 8 characters', async () => {
|
||||
const res = await request(app).post('/api/customers/register').send({
|
||||
email: 'short@example.com',
|
||||
password: '123'
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a malformed email', async () => {
|
||||
const res = await request(app).post('/api/customers/register').send({
|
||||
email: 'not-an-email',
|
||||
password: 'supersecret123'
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-gated routes', () => {
|
||||
it('logs in and can access /me with the returned session cookie', async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post('/api/customers/register').send({
|
||||
email: 'login-test@example.com',
|
||||
password: 'supersecret123'
|
||||
});
|
||||
const me = await agent.get('/api/customers/me');
|
||||
expect(me.status).toBe(200);
|
||||
expect(me.body.email).toBe('login-test@example.com');
|
||||
});
|
||||
|
||||
it('rejects /me with no session', async () => {
|
||||
const res = await request(app).get('/api/customers/me');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects login with the wrong password', async () => {
|
||||
await request(app).post('/api/customers/register').send({
|
||||
email: 'wrongpass@example.com',
|
||||
password: 'supersecret123'
|
||||
});
|
||||
const res = await request(app).post('/api/customers/login').send({
|
||||
email: 'wrongpass@example.com',
|
||||
password: 'incorrect-password'
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import { pool } from '../../src/db';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
describe('GET /api/items', () => {
|
||||
it('returns an empty array when there are no items', async () => {
|
||||
const res = await request(app).get('/api/items');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns items with an empty images array when none are attached', async () => {
|
||||
await pool.query(
|
||||
`INSERT INTO items (name, description, price_cents) VALUES ('Vintage Lamp', 'A one-of-a-kind lamp.', 4500)`
|
||||
);
|
||||
const res = await request(app).get('/api/items');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0]).toMatchObject({
|
||||
name: 'Vintage Lamp',
|
||||
price_cents: 4500,
|
||||
status: 'available',
|
||||
images: []
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 404 for a nonexistent item', async () => {
|
||||
const res = await request(app).get('/api/items/99999');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('demo checkout', () => {
|
||||
it('marks an available item as sold', async () => {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO items (name, price_cents) VALUES ('Test Item', 1000) RETURNING id`
|
||||
);
|
||||
const itemId = rows[0].id;
|
||||
|
||||
const res = await request(app).post(`/api/checkout/demo/${itemId}/purchase`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('sold');
|
||||
|
||||
const check = await request(app).get(`/api/items/${itemId}`);
|
||||
expect(check.body.status).toBe('sold');
|
||||
});
|
||||
|
||||
it('refuses to sell an item that is already sold', async () => {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO items (name, price_cents, status) VALUES ('Already Sold', 1000, 'sold') RETURNING id`
|
||||
);
|
||||
const itemId = rows[0].id;
|
||||
const res = await request(app).post(`/api/checkout/demo/${itemId}/purchase`);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
// Runs before the test framework loads, so app.ts's Postgres pool (created
|
||||
// at import time) connects to the disposable test database instead of
|
||||
// whatever PGHOST/PGUSER happen to be set in the shell environment.
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.PGHOST = process.env.TEST_PGHOST || 'localhost';
|
||||
process.env.PGPORT = process.env.TEST_PGPORT || '55432';
|
||||
process.env.PGUSER = process.env.TEST_PGUSER || 'redefined_test';
|
||||
process.env.PGPASSWORD = process.env.TEST_PGPASSWORD || 'redefined_test';
|
||||
process.env.PGDATABASE = process.env.TEST_PGDATABASE || 'redefined_test';
|
||||
process.env.DEMO_MODE = 'true';
|
||||
process.env.UPLOADS_DIR = '/tmp/redefined-test-uploads';
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Client } from 'pg';
|
||||
|
||||
async function waitForDb(retries = 20): Promise<void> {
|
||||
const config = {
|
||||
host: process.env.TEST_PGHOST || 'localhost',
|
||||
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
|
||||
user: process.env.TEST_PGUSER || 'redefined_test',
|
||||
password: process.env.TEST_PGPASSWORD || 'redefined_test',
|
||||
database: process.env.TEST_PGDATABASE || 'redefined_test'
|
||||
};
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const client = new Client(config);
|
||||
try {
|
||||
await client.connect();
|
||||
await client.end();
|
||||
return;
|
||||
} catch {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'Could not reach the test database on port 55432. Run `npm run db:test:up` before `npm run test:integration`.'
|
||||
);
|
||||
}
|
||||
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
await waitForDb();
|
||||
const { migrate, closeDb } = await import('./testDb');
|
||||
await migrate();
|
||||
await closeDb();
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
import { Pool } from 'pg';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const testPool = new Pool({
|
||||
host: process.env.TEST_PGHOST || 'localhost',
|
||||
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
|
||||
user: process.env.TEST_PGUSER || 'redefined_test',
|
||||
password: process.env.TEST_PGPASSWORD || 'redefined_test',
|
||||
database: process.env.TEST_PGDATABASE || 'redefined_test'
|
||||
});
|
||||
|
||||
export async function migrate(): Promise<void> {
|
||||
const sql = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'init.sql'), 'utf-8');
|
||||
await testPool.query(sql);
|
||||
}
|
||||
|
||||
export async function resetDb(): Promise<void> {
|
||||
await testPool.query(`
|
||||
TRUNCATE TABLE orders, customer_tokens, customer_sessions, customers, item_images, items
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
await testPool.end();
|
||||
}
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
import { toCents, formatPrice, isValidEmail } from '../../src/utils';
|
||||
|
||||
describe('toCents', () => {
|
||||
it('converts a dollar string to integer cents', () => {
|
||||
expect(toCents('19.99')).toBe(1999);
|
||||
});
|
||||
|
||||
it('converts a plain number to integer cents', () => {
|
||||
expect(toCents(5)).toBe(500);
|
||||
});
|
||||
|
||||
it('rounds to the nearest cent', () => {
|
||||
expect(toCents('19.999')).toBe(2000);
|
||||
});
|
||||
|
||||
it('throws on non-numeric input', () => {
|
||||
expect(() => toCents('not-a-number')).toThrow('invalid price');
|
||||
});
|
||||
|
||||
it('throws on a negative price', () => {
|
||||
expect(() => toCents(-5)).toThrow('invalid price');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPrice', () => {
|
||||
it('formats cents as a dollar string', () => {
|
||||
expect(formatPrice(1999)).toBe('$19.99');
|
||||
});
|
||||
|
||||
it('pads to two decimal places', () => {
|
||||
expect(formatPrice(500)).toBe('$5.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEmail', () => {
|
||||
it('accepts a well-formed email', () => {
|
||||
expect(isValidEmail('thom@example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a string with no @', () => {
|
||||
expect(isValidEmail('not-an-email')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an email with no domain', () => {
|
||||
expect(isValidEmail('thom@')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,9 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc && vite build"
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
@@ -20,6 +22,7 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0"
|
||||
"vite": "^5.4.0",
|
||||
"@playwright/test": "^1.47.0"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry'
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30000
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }]
|
||||
});
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
function uniqueEmail(): string {
|
||||
return `playwright-${Date.now()}-${Math.floor(Math.random() * 10000)}@example.com`;
|
||||
}
|
||||
|
||||
test.describe('Customer accounts', () => {
|
||||
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
await expect(page.getByRole('checkbox')).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('registers a new account and lands on the account page', async ({ page }) => {
|
||||
const email = uniqueEmail();
|
||||
await page.goto('/register');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill('supersecret123');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page).toHaveURL(/\/account/);
|
||||
await expect(page.getByText(email)).toBeVisible();
|
||||
});
|
||||
|
||||
test('rejects login with the wrong password', async ({ page }) => {
|
||||
const email = uniqueEmail();
|
||||
await page.goto('/register');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill('supersecret123');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page).toHaveURL(/\/account/);
|
||||
|
||||
await page.getByRole('button', { name: 'Log out' }).click();
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill('wrong-password');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Storefront', () => {
|
||||
test('loads and shows the site title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Redefined Designs')).toBeVisible();
|
||||
});
|
||||
|
||||
test('links to the privacy policy from the footer', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: 'Privacy Policy' }).click();
|
||||
await expect(page).toHaveURL(/\/privacy/);
|
||||
await expect(page.getByText('What we collect')).toBeVisible();
|
||||
});
|
||||
|
||||
test('offers login and sign up when logged out', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Theme switching', () => {
|
||||
test('toggling the switch changes the body theme attribute', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const body = page.locator('body');
|
||||
const initial = await body.getAttribute('data-theme');
|
||||
|
||||
await page.getByRole('switch').click();
|
||||
|
||||
await expect(async () => {
|
||||
const updated = await body.getAttribute('data-theme');
|
||||
expect(updated).not.toBe(initial);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('theme preference persists across a reload', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('switch').click();
|
||||
const chosen = await page.locator('body').getAttribute('data-theme');
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('body')).toHaveAttribute('data-theme', chosen || '');
|
||||
});
|
||||
});
|
||||
@@ -3,5 +3,12 @@ import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: { outDir: 'dist' }
|
||||
build: { outDir: 'dist' },
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/uploads': 'http://localhost:3000',
|
||||
'/webhooks': 'http://localhost:3000'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user