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:
+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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user