qa-copilot-lab 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/bin/cli.js +11 -0
- package/image/1TB External SSD.jpg +0 -0
- package/image/Bluetooth Speaker.jpg +0 -0
- package/image/Ethernet Cable 3m.jpg +0 -0
- package/image/Laptop Stand.jpg +0 -0
- package/image/Mechanical Keyboard.jpg +0 -0
- package/image/Noise Cancelling Headphones.jpg +0 -0
- package/image/USB-C Hub.jpg +0 -0
- package/image/Webcam 1080p.jpg +0 -0
- package/image/Wi-Fi 6 Router.jpg +0 -0
- package/image/wireless mouse.jpg +0 -0
- package/mouse.jpg +0 -0
- package/package.json +26 -0
- package/public/css/style.css +169 -0
- package/public/images/products/1.jpg +0 -0
- package/public/images/products/10.jpg +0 -0
- package/public/images/products/2.jpg +0 -0
- package/public/images/products/3.jpg +0 -0
- package/public/images/products/4.jpg +0 -0
- package/public/images/products/5.jpg +0 -0
- package/public/images/products/6.jpg +0 -0
- package/public/images/products/7.jpg +0 -0
- package/public/images/products/8.jpg +0 -0
- package/public/images/products/9.jpg +0 -0
- package/public/js/accordion.js +10 -0
- package/public/js/autocomplete.js +39 -0
- package/public/js/cart.js +52 -0
- package/public/js/confirm-dialog.js +8 -0
- package/public/js/drag-drop.js +44 -0
- package/public/js/reveal-answer.js +10 -0
- package/public/js/star-rating.js +38 -0
- package/public/js/tabs.js +14 -0
- package/public/js/theme-toggle.js +24 -0
- package/public/js/toast.js +10 -0
- package/qa-copilot-lab-0.1.0.tgz +0 -0
- package/src/chaos.js +18 -0
- package/src/db.js +97 -0
- package/src/middleware/session.js +16 -0
- package/src/routes/api.js +136 -0
- package/src/routes/auth.js +45 -0
- package/src/routes/devApi.js +56 -0
- package/src/routes/pages.js +125 -0
- package/src/seed.js +43 -0
- package/src/server.js +71 -0
- package/src/state.js +33 -0
- package/views/404.ejs +7 -0
- package/views/account.ejs +42 -0
- package/views/cart.ejs +42 -0
- package/views/catalog.ejs +69 -0
- package/views/checkout.ejs +49 -0
- package/views/home.ejs +42 -0
- package/views/invoice.ejs +18 -0
- package/views/locators.ejs +323 -0
- package/views/login.ejs +22 -0
- package/views/orders.ejs +33 -0
- package/views/partials/footer.ejs +18 -0
- package/views/partials/head.ejs +9 -0
- package/views/partials/header.ejs +39 -0
- package/views/payment-frame.ejs +26 -0
- package/views/product.ejs +77 -0
- package/views/register.ejs +25 -0
package/src/db.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const Database = require('better-sqlite3');
|
|
5
|
+
const { products, reviews, faqs, demoUser, demoOrders } = require('./seed');
|
|
6
|
+
|
|
7
|
+
const DB_DIR = path.join(os.homedir(), '.qa-copilot-lab');
|
|
8
|
+
const DB_PATH = path.join(DB_DIR, 'data.db');
|
|
9
|
+
|
|
10
|
+
function migrate(db) {
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS products (
|
|
13
|
+
id INTEGER PRIMARY KEY,
|
|
14
|
+
name TEXT NOT NULL,
|
|
15
|
+
category TEXT NOT NULL,
|
|
16
|
+
price REAL NOT NULL,
|
|
17
|
+
stock INTEGER NOT NULL,
|
|
18
|
+
color TEXT NOT NULL
|
|
19
|
+
);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS reviews (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
product_id INTEGER NOT NULL,
|
|
23
|
+
author TEXT NOT NULL,
|
|
24
|
+
rating INTEGER NOT NULL,
|
|
25
|
+
body TEXT NOT NULL
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS faqs (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
question TEXT NOT NULL,
|
|
30
|
+
answer TEXT NOT NULL
|
|
31
|
+
);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
33
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
34
|
+
username TEXT UNIQUE NOT NULL,
|
|
35
|
+
password TEXT NOT NULL,
|
|
36
|
+
email TEXT NOT NULL
|
|
37
|
+
);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS orders (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
username TEXT NOT NULL,
|
|
41
|
+
created_at TEXT NOT NULL,
|
|
42
|
+
item TEXT NOT NULL,
|
|
43
|
+
qty INTEGER NOT NULL,
|
|
44
|
+
total REAL NOT NULL,
|
|
45
|
+
status TEXT NOT NULL
|
|
46
|
+
);
|
|
47
|
+
CREATE TABLE IF NOT EXISTS wishlist (
|
|
48
|
+
username TEXT NOT NULL,
|
|
49
|
+
product_id INTEGER NOT NULL,
|
|
50
|
+
position INTEGER NOT NULL
|
|
51
|
+
);
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function seedIfEmpty(db) {
|
|
56
|
+
const { count } = db.prepare('SELECT COUNT(*) AS count FROM products').get();
|
|
57
|
+
if (count > 0) return;
|
|
58
|
+
|
|
59
|
+
const insertProduct = db.prepare(
|
|
60
|
+
'INSERT INTO products (id, name, category, price, stock, color) VALUES (@id, @name, @category, @price, @stock, @color)'
|
|
61
|
+
);
|
|
62
|
+
const insertReview = db.prepare(
|
|
63
|
+
'INSERT INTO reviews (product_id, author, rating, body) VALUES (@product_id, @author, @rating, @body)'
|
|
64
|
+
);
|
|
65
|
+
const insertFaq = db.prepare('INSERT INTO faqs (question, answer) VALUES (@question, @answer)');
|
|
66
|
+
const insertUser = db.prepare('INSERT INTO users (username, password, email) VALUES (@username, @password, @email)');
|
|
67
|
+
const insertOrder = db.prepare(
|
|
68
|
+
'INSERT INTO orders (id, username, created_at, item, qty, total, status) VALUES (@id, @username, @created_at, @item, @qty, @total, @status)'
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const seedAll = db.transaction(() => {
|
|
72
|
+
for (const p of products) insertProduct.run(p);
|
|
73
|
+
for (const r of reviews) insertReview.run(r);
|
|
74
|
+
for (const f of faqs) insertFaq.run(f);
|
|
75
|
+
insertUser.run(demoUser);
|
|
76
|
+
for (const o of demoOrders) insertOrder.run(o);
|
|
77
|
+
});
|
|
78
|
+
seedAll();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function openDb({ fresh = false } = {}) {
|
|
82
|
+
fs.mkdirSync(DB_DIR, { recursive: true });
|
|
83
|
+
if (fresh && fs.existsSync(DB_PATH)) fs.rmSync(DB_PATH);
|
|
84
|
+
|
|
85
|
+
const db = new Database(DB_PATH);
|
|
86
|
+
db.pragma('journal_mode = WAL');
|
|
87
|
+
migrate(db);
|
|
88
|
+
seedIfEmpty(db);
|
|
89
|
+
return db;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resetData(db) {
|
|
93
|
+
db.exec('DELETE FROM products; DELETE FROM reviews; DELETE FROM faqs; DELETE FROM users; DELETE FROM orders; DELETE FROM wishlist;');
|
|
94
|
+
seedIfEmpty(db);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { openDb, resetData, DB_PATH };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
// Sandbox-simple session: a random id in a cookie, cart looked up by that id.
|
|
4
|
+
// No auth token here — logged-in username lives in its own cookie, set by /login.
|
|
5
|
+
function sessionMiddleware(req, res, next) {
|
|
6
|
+
let sid = req.cookies.sid;
|
|
7
|
+
if (!sid) {
|
|
8
|
+
sid = crypto.randomUUID();
|
|
9
|
+
res.cookie('sid', sid, { httpOnly: true });
|
|
10
|
+
}
|
|
11
|
+
req.sid = sid;
|
|
12
|
+
req.username = req.cookies.username || null;
|
|
13
|
+
next();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = sessionMiddleware;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const state = require('../state');
|
|
5
|
+
|
|
6
|
+
const PRODUCT_PHOTO_DIR = path.join(__dirname, '..', '..', 'public', 'images', 'products');
|
|
7
|
+
|
|
8
|
+
function initials(name) {
|
|
9
|
+
return name
|
|
10
|
+
.split(' ')
|
|
11
|
+
.map((w) => w[0])
|
|
12
|
+
.slice(0, 2)
|
|
13
|
+
.join('')
|
|
14
|
+
.toUpperCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function apiRouter(db) {
|
|
18
|
+
const router = express.Router();
|
|
19
|
+
|
|
20
|
+
// Real product photo if one was dropped in public/images/products/<id>.jpg,
|
|
21
|
+
// otherwise fall back to placeholder art generated as inline SVG — either way
|
|
22
|
+
// every product gets a real <img alt="..."> to practice getByAltText on.
|
|
23
|
+
router.get('/img/:id', (req, res) => {
|
|
24
|
+
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
|
|
25
|
+
if (!product) return res.status(404).end();
|
|
26
|
+
|
|
27
|
+
const photoPath = path.join(PRODUCT_PHOTO_DIR, `${product.id}.jpg`);
|
|
28
|
+
if (fs.existsSync(photoPath)) return res.type('image/jpeg').sendFile(photoPath);
|
|
29
|
+
|
|
30
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="220">
|
|
31
|
+
<rect width="320" height="220" fill="#${product.color}"/>
|
|
32
|
+
<text x="160" y="118" font-family="monospace" font-size="42" fill="#ffffff" text-anchor="middle">${initials(product.name)}</text>
|
|
33
|
+
</svg>`;
|
|
34
|
+
res.type('image/svg+xml').send(svg);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
router.get('/products', (req, res) => {
|
|
38
|
+
const { category, maxPrice } = req.query;
|
|
39
|
+
let sql = 'SELECT * FROM products WHERE 1=1';
|
|
40
|
+
const params = [];
|
|
41
|
+
if (category) {
|
|
42
|
+
sql += ' AND category = ?';
|
|
43
|
+
params.push(category);
|
|
44
|
+
}
|
|
45
|
+
if (maxPrice) {
|
|
46
|
+
sql += ' AND price <= ?';
|
|
47
|
+
params.push(Number(maxPrice));
|
|
48
|
+
}
|
|
49
|
+
res.json(db.prepare(sql).all(...params));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
router.get('/products/:id', (req, res) => {
|
|
53
|
+
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
|
|
54
|
+
if (!product) return res.status(404).json({ error: 'not found' });
|
|
55
|
+
res.json(product);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
router.get('/cart', (req, res) => {
|
|
59
|
+
res.json(hydrateCart(db, state.getCart(req.sid)));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
router.post('/cart', express.json(), (req, res) => {
|
|
63
|
+
const { productId, qty = 1 } = req.body;
|
|
64
|
+
const cart = state.getCart(req.sid);
|
|
65
|
+
const existing = cart.find((line) => line.productId === Number(productId));
|
|
66
|
+
if (existing && state.getBugSet() !== 2) {
|
|
67
|
+
existing.qty += Number(qty);
|
|
68
|
+
} else {
|
|
69
|
+
cart.push({ productId: Number(productId), qty: Number(qty) });
|
|
70
|
+
}
|
|
71
|
+
res.status(201).json(hydrateCart(db, cart));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
router.patch('/cart/:productId', express.json(), (req, res) => {
|
|
75
|
+
const cart = state.getCart(req.sid);
|
|
76
|
+
const line = cart.find((l) => l.productId === Number(req.params.productId));
|
|
77
|
+
if (!line) return res.status(404).json({ error: 'not in cart' });
|
|
78
|
+
line.qty = Math.max(1, Number(req.body.qty));
|
|
79
|
+
res.json(hydrateCart(db, cart));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
router.delete('/cart/:productId', (req, res) => {
|
|
83
|
+
const cart = state.getCart(req.sid);
|
|
84
|
+
const next = cart.filter((l) => l.productId !== Number(req.params.productId));
|
|
85
|
+
state.getCart(req.sid).length = 0;
|
|
86
|
+
state.getCart(req.sid).push(...next);
|
|
87
|
+
res.json(hydrateCart(db, next));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
router.post('/orders', express.json(), async (req, res) => {
|
|
91
|
+
if (!req.username) return res.status(401).json({ error: 'login required' });
|
|
92
|
+
const cart = state.getCart(req.sid);
|
|
93
|
+
if (cart.length === 0) return res.status(400).json({ error: 'cart is empty' });
|
|
94
|
+
|
|
95
|
+
const hydrated = hydrateCart(db, cart);
|
|
96
|
+
const total = hydrated.items.reduce((sum, l) => sum + l.price * l.qty, 0);
|
|
97
|
+
const qty = hydrated.items.reduce((n, l) => n + l.qty, 0);
|
|
98
|
+
const itemSummary = hydrated.items.map((l) => `${l.name} x${l.qty}`).join(', ');
|
|
99
|
+
|
|
100
|
+
// Flaky mode: a payment-gateway-style delay opens a real await gap, so a
|
|
101
|
+
// rapid double-click on "Place order" reads the same cart snapshot twice
|
|
102
|
+
// and creates two orders — a genuine race, not a scripted one.
|
|
103
|
+
if (state.getFlaky()) {
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 300 + Math.random() * 900));
|
|
105
|
+
if (Math.random() < 0.25) {
|
|
106
|
+
return res.status(500).json({ error: 'Payment gateway timed out. Please try again.' });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const id = `SKU-${1100 + Math.floor(Math.random() * 900)}`;
|
|
111
|
+
db.prepare(
|
|
112
|
+
'INSERT INTO orders (id, username, created_at, item, qty, total, status) VALUES (?, ?, date(?), ?, ?, ?, ?)'
|
|
113
|
+
).run(id, req.username, 'now', itemSummary, qty, total, 'Processing');
|
|
114
|
+
|
|
115
|
+
cart.length = 0;
|
|
116
|
+
res.status(201).json({ id, total });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
router.get('/orders', (req, res) => {
|
|
120
|
+
if (!req.username) return res.status(401).json({ error: 'login required' });
|
|
121
|
+
res.json(db.prepare('SELECT * FROM orders WHERE username = ? ORDER BY created_at DESC').all(req.username));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return router;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hydrateCart(db, cart) {
|
|
128
|
+
const items = cart.map((line) => {
|
|
129
|
+
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(line.productId);
|
|
130
|
+
return { ...line, name: product.name, price: product.price, stock: product.stock };
|
|
131
|
+
});
|
|
132
|
+
const count = items.reduce((n, l) => n + l.qty, 0);
|
|
133
|
+
return { items, count };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { apiRouter, hydrateCart };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
|
|
3
|
+
// Sandbox auth: plaintext compare against a local SQLite table. This app never
|
|
4
|
+
// leaves the student's machine and holds no real user data, so there is no
|
|
5
|
+
// hashing/session-store machinery to teach around here on purpose.
|
|
6
|
+
function authRouter(db) {
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
router.post('/login', express.urlencoded({ extended: false }), (req, res) => {
|
|
10
|
+
const { username, password } = req.body;
|
|
11
|
+
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
|
12
|
+
if (!user || user.password !== password) {
|
|
13
|
+
return res.redirect('/login?error=invalid');
|
|
14
|
+
}
|
|
15
|
+
res.cookie('username', user.username, { httpOnly: true });
|
|
16
|
+
res.redirect('/account');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
router.post('/register', express.urlencoded({ extended: false }), (req, res) => {
|
|
20
|
+
const { username, password, email } = req.body;
|
|
21
|
+
if (!username || !password || !email) return res.redirect('/register?error=missing');
|
|
22
|
+
try {
|
|
23
|
+
db.prepare('INSERT INTO users (username, password, email) VALUES (?, ?, ?)').run(username, password, email);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return res.redirect('/register?error=taken');
|
|
26
|
+
}
|
|
27
|
+
res.cookie('username', username, { httpOnly: true });
|
|
28
|
+
res.redirect('/account');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
router.post('/logout', (req, res) => {
|
|
32
|
+
res.clearCookie('username');
|
|
33
|
+
res.redirect('/');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
router.post('/account/delete', (req, res) => {
|
|
37
|
+
if (req.username) db.prepare('DELETE FROM users WHERE username = ?').run(req.username);
|
|
38
|
+
res.clearCookie('username');
|
|
39
|
+
res.redirect('/');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return router;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { authRouter };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const state = require('../state');
|
|
3
|
+
const { resetData } = require('../db');
|
|
4
|
+
|
|
5
|
+
const BUG_SETS = {
|
|
6
|
+
1: 'Checkout button stays disabled even once the cart has items.',
|
|
7
|
+
2: 'Adding the same product twice creates a duplicate line instead of incrementing quantity.',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function devApiRouter(db) {
|
|
11
|
+
const router = express.Router();
|
|
12
|
+
|
|
13
|
+
router.get('/__health', (req, res) => {
|
|
14
|
+
res.json({ ok: true, chaos: state.getChaosMode(), bugSet: state.getBugSet(), flaky: state.getFlaky() });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
router.post('/__reset', (req, res) => {
|
|
18
|
+
resetData(db);
|
|
19
|
+
state.getCart(req.sid).length = 0;
|
|
20
|
+
state.setChaos('off');
|
|
21
|
+
state.setBugSet(null);
|
|
22
|
+
state.setFlaky(false);
|
|
23
|
+
res.json({ ok: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
router.post('/__chaos/:mode', (req, res) => {
|
|
27
|
+
const { mode } = req.params;
|
|
28
|
+
if (!['off', 'mild', 'aggressive'].includes(mode)) {
|
|
29
|
+
return res.status(400).json({ error: 'mode must be off, mild, or aggressive' });
|
|
30
|
+
}
|
|
31
|
+
state.setChaos(mode);
|
|
32
|
+
res.json({ ok: true, chaos: mode });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
router.post('/__seed-bugs/:setId', (req, res) => {
|
|
36
|
+
const setId = req.params.setId === 'off' ? null : Number(req.params.setId);
|
|
37
|
+
if (setId !== null && !BUG_SETS[setId]) {
|
|
38
|
+
return res.status(400).json({ error: `unknown bug set, known sets: ${Object.keys(BUG_SETS).join(', ')}` });
|
|
39
|
+
}
|
|
40
|
+
state.setBugSet(setId);
|
|
41
|
+
res.json({ ok: true, bugSet: setId, answerKey: setId ? BUG_SETS[setId] : null });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
router.post('/__flaky/:mode', (req, res) => {
|
|
45
|
+
const { mode } = req.params;
|
|
46
|
+
if (!['on', 'off'].includes(mode)) {
|
|
47
|
+
return res.status(400).json({ error: 'mode must be on or off' });
|
|
48
|
+
}
|
|
49
|
+
state.setFlaky(mode === 'on');
|
|
50
|
+
res.json({ ok: true, flaky: mode === 'on' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return router;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { devApiRouter, BUG_SETS };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const state = require('../state');
|
|
3
|
+
const { hydrateCart } = require('./api');
|
|
4
|
+
|
|
5
|
+
const CATEGORIES = ['Peripherals', 'Audio', 'Storage', 'Networking', 'Accessories'];
|
|
6
|
+
const PAGE_SIZE = 6;
|
|
7
|
+
|
|
8
|
+
function pagesRouter(db) {
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
|
|
11
|
+
router.get('/', (req, res) => {
|
|
12
|
+
const featured = db.prepare('SELECT * FROM products ORDER BY id LIMIT 4').all();
|
|
13
|
+
const faqs = db.prepare('SELECT * FROM faqs').all();
|
|
14
|
+
res.render('home', { featured, faqs });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
router.get('/catalog', (req, res) => {
|
|
18
|
+
const category = req.query.category || '';
|
|
19
|
+
const maxPrice = req.query.maxPrice ? Number(req.query.maxPrice) : null;
|
|
20
|
+
const q = req.query.q || '';
|
|
21
|
+
const page = Math.max(1, Number(req.query.page) || 1);
|
|
22
|
+
|
|
23
|
+
let sql = 'SELECT * FROM products WHERE 1=1';
|
|
24
|
+
const params = [];
|
|
25
|
+
if (category) {
|
|
26
|
+
sql += ' AND category = ?';
|
|
27
|
+
params.push(category);
|
|
28
|
+
}
|
|
29
|
+
if (maxPrice) {
|
|
30
|
+
sql += ' AND price <= ?';
|
|
31
|
+
params.push(maxPrice);
|
|
32
|
+
}
|
|
33
|
+
if (q) {
|
|
34
|
+
sql += ' AND name LIKE ?';
|
|
35
|
+
params.push(`%${q}%`);
|
|
36
|
+
}
|
|
37
|
+
const all = db.prepare(sql).all(...params);
|
|
38
|
+
const totalPages = Math.max(1, Math.ceil(all.length / PAGE_SIZE));
|
|
39
|
+
const products = all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
|
40
|
+
|
|
41
|
+
res.render('catalog', { products, categories: CATEGORIES, category, maxPrice, q, page, totalPages });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
router.get('/product/:id', (req, res) => {
|
|
45
|
+
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
|
|
46
|
+
if (!product) return res.status(404).render('404');
|
|
47
|
+
const productReviews = db.prepare('SELECT * FROM reviews WHERE product_id = ?').all(product.id);
|
|
48
|
+
res.render('product', { product, reviews: productReviews });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
router.get('/cart', (req, res) => {
|
|
52
|
+
const cart = hydrateCart(db, state.getCart(req.sid));
|
|
53
|
+
res.render('cart', { cart, bugSet: state.getBugSet() });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
router.get('/checkout', (req, res) => {
|
|
57
|
+
const cart = hydrateCart(db, state.getCart(req.sid));
|
|
58
|
+
if (cart.items.length === 0) return res.redirect('/cart');
|
|
59
|
+
res.render('checkout', { cart });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
router.get('/payment-frame', (req, res) => {
|
|
63
|
+
res.render('payment-frame', { layout: false });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
router.get('/login', (req, res) => {
|
|
67
|
+
res.render('login', { error: req.query.error || null });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
router.get('/register', (req, res) => {
|
|
71
|
+
res.render('register', { error: req.query.error || null });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
router.get('/account', (req, res) => {
|
|
75
|
+
if (!req.username) return res.redirect('/login');
|
|
76
|
+
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(req.username);
|
|
77
|
+
const wishlist = db
|
|
78
|
+
.prepare(
|
|
79
|
+
`SELECT wishlist.product_id AS id, products.name, products.price
|
|
80
|
+
FROM wishlist JOIN products ON products.id = wishlist.product_id
|
|
81
|
+
WHERE wishlist.username = ? ORDER BY wishlist.position`
|
|
82
|
+
)
|
|
83
|
+
.all(req.username);
|
|
84
|
+
res.render('account', { user, wishlist });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
router.get('/account/orders', (req, res) => {
|
|
88
|
+
if (!req.username) return res.redirect('/login');
|
|
89
|
+
const orders = db.prepare('SELECT * FROM orders WHERE username = ? ORDER BY created_at DESC').all(req.username);
|
|
90
|
+
res.render('orders', { orders });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
router.get('/account/orders/:id/invoice', (req, res) => {
|
|
94
|
+
if (!req.username) return res.redirect('/login');
|
|
95
|
+
const order = db.prepare('SELECT * FROM orders WHERE id = ? AND username = ?').get(req.params.id, req.username);
|
|
96
|
+
if (!order) return res.status(404).render('404');
|
|
97
|
+
res.render('invoice', { order, layout: false });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
router.post('/wishlist', express.urlencoded({ extended: false }), (req, res) => {
|
|
101
|
+
if (!req.username) return res.redirect('/login');
|
|
102
|
+
const { productId, order } = req.body;
|
|
103
|
+
if (order) {
|
|
104
|
+
const ids = order.split(',').map(Number);
|
|
105
|
+
const update = db.prepare('UPDATE wishlist SET position = ? WHERE username = ? AND product_id = ?');
|
|
106
|
+
ids.forEach((id, i) => update.run(i, req.username, id));
|
|
107
|
+
} else if (productId) {
|
|
108
|
+
const { count } = db.prepare('SELECT COUNT(*) AS count FROM wishlist WHERE username = ?').get(req.username);
|
|
109
|
+
db.prepare('INSERT INTO wishlist (username, product_id, position) VALUES (?, ?, ?)').run(
|
|
110
|
+
req.username,
|
|
111
|
+
Number(productId),
|
|
112
|
+
count
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
res.redirect('/account');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
router.get('/locators', (req, res) => {
|
|
119
|
+
res.render('locators');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return router;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { pagesRouter };
|
package/src/seed.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const products = [
|
|
2
|
+
{ id: 1, name: 'Wireless Mouse', category: 'Peripherals', price: 19.99, stock: 25, color: '2b6ca3' },
|
|
3
|
+
{ id: 2, name: 'Mechanical Keyboard', category: 'Peripherals', price: 89.0, stock: 0, color: '1f7a5c' },
|
|
4
|
+
{ id: 3, name: 'USB-C Hub (7-in-1)', category: 'Accessories', price: 34.5, stock: 40, color: 'b85c2c' },
|
|
5
|
+
{ id: 4, name: 'Noise Cancelling Headphones', category: 'Audio', price: 129.0, stock: 12, color: '6c4fa1' },
|
|
6
|
+
{ id: 5, name: '1TB External SSD', category: 'Storage', price: 79.99, stock: 8, color: '2b6ca3' },
|
|
7
|
+
{ id: 6, name: 'Webcam 1080p', category: 'Peripherals', price: 45.0, stock: 15, color: '1f7a5c' },
|
|
8
|
+
{ id: 7, name: 'Wi-Fi 6 Router', category: 'Networking', price: 99.0, stock: 5, color: 'b85c2c' },
|
|
9
|
+
{ id: 8, name: 'Laptop Stand', category: 'Accessories', price: 29.0, stock: 0, color: '6c4fa1' },
|
|
10
|
+
{ id: 9, name: 'Bluetooth Speaker', category: 'Audio', price: 59.0, stock: 20, color: '2b6ca3' },
|
|
11
|
+
{ id: 10, name: 'Ethernet Cable 3m', category: 'Networking', price: 8.0, stock: 100, color: '1f7a5c' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const reviews = [
|
|
15
|
+
{ product_id: 1, author: 'Nok', rating: 5, body: 'Tracks perfectly, battery lasts weeks.' },
|
|
16
|
+
{ product_id: 1, author: 'Ploy', rating: 4, body: 'Comfortable grip, a bit light for my taste.' },
|
|
17
|
+
{ product_id: 1, author: 'Beam', rating: 5, body: 'Silent clicks, great for open offices.' },
|
|
18
|
+
{ product_id: 1, author: 'Fah', rating: 3, body: 'Scroll wheel feels loose after a month.' },
|
|
19
|
+
{ product_id: 1, author: 'Ann', rating: 5, body: 'Best budget mouse I have used.' },
|
|
20
|
+
{ product_id: 1, author: 'Kong', rating: 4, body: 'Pairs instantly with my laptop every time.' },
|
|
21
|
+
{ product_id: 1, author: 'Mai', rating: 2, body: 'One arrived with a dead scroll click.' },
|
|
22
|
+
{ product_id: 1, author: 'Toon', rating: 5, body: 'Exactly as described, fast shipping.' },
|
|
23
|
+
{ product_id: 4, author: 'Gift', rating: 5, body: 'Cancels office chatter almost completely.' },
|
|
24
|
+
{ product_id: 4, author: 'Ohm', rating: 4, body: 'Great sound, ear cups run warm after hours.' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const faqs = [
|
|
28
|
+
{ question: 'How long is shipping?', answer: 'Orders ship within 2 business days and arrive in 3-5 days domestically.' },
|
|
29
|
+
{ question: 'Can I return an item?', answer: 'Yes, unopened items can be returned within 30 days of delivery.' },
|
|
30
|
+
{ question: 'Do you ship internationally?', answer: 'Currently we only ship within the country used at checkout.' },
|
|
31
|
+
{ question: 'Is my payment information stored?', answer: 'No, card details are handled entirely by the payment step and never saved.' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const demoUser = { username: 'qa_student', password: 'Passw0rd!', email: 'qa_student@example.test' };
|
|
35
|
+
|
|
36
|
+
const demoOrders = [
|
|
37
|
+
{ id: 'SKU-1027', username: 'qa_student', created_at: '2026-07-02', item: 'Wireless Mouse', qty: 1, total: 19.99, status: 'Delivered' },
|
|
38
|
+
{ id: 'SKU-1029', username: 'qa_student', created_at: '2026-07-18', item: 'USB-C Hub (7-in-1)', qty: 2, total: 69.0, status: 'Delivered' },
|
|
39
|
+
{ id: 'SKU-1041', username: 'qa_student', created_at: '2026-08-05', item: 'Noise Cancelling Headphones', qty: 1, total: 129.0, status: 'Shipped' },
|
|
40
|
+
{ id: 'SKU-1052', username: 'qa_student', created_at: '2026-08-30', item: 'Bluetooth Speaker', qty: 1, total: 59.0, status: 'Processing' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
module.exports = { products, reviews, faqs, demoUser, demoOrders };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const cookieParser = require('cookie-parser');
|
|
4
|
+
|
|
5
|
+
const { openDb, DB_PATH } = require('./db');
|
|
6
|
+
const state = require('./state');
|
|
7
|
+
const { cx, testId } = require('./chaos');
|
|
8
|
+
const sessionMiddleware = require('./middleware/session');
|
|
9
|
+
const { apiRouter, hydrateCart } = require('./routes/api');
|
|
10
|
+
const { pagesRouter } = require('./routes/pages');
|
|
11
|
+
const { authRouter } = require('./routes/auth');
|
|
12
|
+
const { devApiRouter } = require('./routes/devApi');
|
|
13
|
+
|
|
14
|
+
function createApp(db) {
|
|
15
|
+
const app = express();
|
|
16
|
+
|
|
17
|
+
app.set('view engine', 'ejs');
|
|
18
|
+
app.set('views', path.join(__dirname, '..', 'views'));
|
|
19
|
+
|
|
20
|
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
21
|
+
app.use(cookieParser());
|
|
22
|
+
app.use(sessionMiddleware);
|
|
23
|
+
|
|
24
|
+
// Every EJS view gets these without asking — locators sprinkled through the
|
|
25
|
+
// markup (data-testid, chaos-mode class names) read from one shared source.
|
|
26
|
+
app.use((req, res, next) => {
|
|
27
|
+
res.locals.username = req.username;
|
|
28
|
+
res.locals.cartCount = hydrateCart(db, state.getCart(req.sid)).count;
|
|
29
|
+
res.locals.chaosMode = state.getChaosMode();
|
|
30
|
+
res.locals.bugSet = state.getBugSet();
|
|
31
|
+
res.locals.cx = cx;
|
|
32
|
+
res.locals.tid = testId;
|
|
33
|
+
next();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.use('/api', apiRouter(db));
|
|
37
|
+
app.use('/api', devApiRouter(db));
|
|
38
|
+
app.use(authRouter(db));
|
|
39
|
+
app.use(pagesRouter(db));
|
|
40
|
+
|
|
41
|
+
app.use((req, res) => {
|
|
42
|
+
res.status(404).render('404');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return app;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function startServer({ port = 4173, fresh = false, host = '127.0.0.1' } = {}) {
|
|
49
|
+
const db = openDb({ fresh });
|
|
50
|
+
const app = createApp(db);
|
|
51
|
+
|
|
52
|
+
app.listen(port, host, () => {
|
|
53
|
+
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
|
54
|
+
console.log('');
|
|
55
|
+
console.log(` QA Copilot Lab running at http://${displayHost}:${port}`);
|
|
56
|
+
if (host === '0.0.0.0') {
|
|
57
|
+
console.log(` bound to 0.0.0.0 — reachable from other devices on this network too`);
|
|
58
|
+
}
|
|
59
|
+
console.log(` seeded login: qa_student / Passw0rd!`);
|
|
60
|
+
console.log(` data stored at ${DB_PATH}`);
|
|
61
|
+
console.log(` reset: POST /api/__reset`);
|
|
62
|
+
console.log(` chaos mode: POST /api/__chaos/mild | aggressive | off`);
|
|
63
|
+
console.log(` seed bugs: POST /api/__seed-bugs/1 | 2 | off`);
|
|
64
|
+
console.log(` flaky mode: POST /api/__flaky/on | off`);
|
|
65
|
+
console.log('');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return app;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { createApp, startServer };
|
package/src/state.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Runtime-only state for exercises: intentionally NOT persisted to SQLite.
|
|
2
|
+
// Every restart starts clean (chaos off, no seeded bugs) — students opt in on purpose.
|
|
3
|
+
const carts = new Map(); // sid -> [{ productId, qty }]
|
|
4
|
+
|
|
5
|
+
let chaosMode = 'off'; // 'off' | 'mild' | 'aggressive'
|
|
6
|
+
let chaosSeed = 'stable';
|
|
7
|
+
let activeBugSet = null; // null | 1 | 2
|
|
8
|
+
let flakyMode = false; // checkout endpoint gains latency, occasional 500s, and a real race window
|
|
9
|
+
|
|
10
|
+
function getCart(sid) {
|
|
11
|
+
if (!carts.has(sid)) carts.set(sid, []);
|
|
12
|
+
return carts.get(sid);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function setChaos(mode) {
|
|
16
|
+
chaosMode = mode;
|
|
17
|
+
chaosSeed = Math.random().toString(36).slice(2, 8);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
getCart,
|
|
22
|
+
getChaosMode: () => chaosMode,
|
|
23
|
+
getChaosSeed: () => chaosSeed,
|
|
24
|
+
setChaos,
|
|
25
|
+
getBugSet: () => activeBugSet,
|
|
26
|
+
setBugSet: (id) => {
|
|
27
|
+
activeBugSet = id;
|
|
28
|
+
},
|
|
29
|
+
getFlaky: () => flakyMode,
|
|
30
|
+
setFlaky: (on) => {
|
|
31
|
+
flakyMode = on;
|
|
32
|
+
},
|
|
33
|
+
};
|
package/views/404.ejs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'Account' }) %>
|
|
2
|
+
<%- include('partials/header') %>
|
|
3
|
+
|
|
4
|
+
<h1>Account</h1>
|
|
5
|
+
<p>Username: <strong><%= user.username %></strong></p>
|
|
6
|
+
<p>Email: <strong><%= user.email %></strong></p>
|
|
7
|
+
|
|
8
|
+
<section class="section">
|
|
9
|
+
<h2>Profile photo</h2>
|
|
10
|
+
<form class="upload-form" onsubmit="event.preventDefault(); window.toast('Photo received (nothing is actually stored in this sandbox)');">
|
|
11
|
+
<label for="profile-photo">Upload a photo</label>
|
|
12
|
+
<input type="file" id="profile-photo" name="photo" accept="image/*">
|
|
13
|
+
<button type="submit" class="btn btn-secondary">Upload</button>
|
|
14
|
+
</form>
|
|
15
|
+
</section>
|
|
16
|
+
|
|
17
|
+
<section class="section">
|
|
18
|
+
<h2>Wishlist</h2>
|
|
19
|
+
<p class="hint-text">Drag to reorder — the new order is saved automatically.</p>
|
|
20
|
+
<ul class="wishlist" id="wishlist-list" data-component="drag-drop">
|
|
21
|
+
<% wishlist.forEach(function(w) { %>
|
|
22
|
+
<li draggable="true" data-product-id="<%= w.id %>" class="wishlist-item" data-testid="<%= tid('wishlist-item-' + w.id) %>">
|
|
23
|
+
<span class="drag-handle" aria-hidden="true">⠿</span>
|
|
24
|
+
<%= w.name %> — <%= w.price.toFixed(2) %> USD
|
|
25
|
+
</li>
|
|
26
|
+
<% }); %>
|
|
27
|
+
<% if (wishlist.length === 0) { %>
|
|
28
|
+
<li class="wishlist-empty">Nothing saved yet — add items from any product page.</li>
|
|
29
|
+
<% } %>
|
|
30
|
+
</ul>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
<section class="section">
|
|
34
|
+
<h2>Danger zone</h2>
|
|
35
|
+
<form action="/account/delete" method="post" id="delete-account-form">
|
|
36
|
+
<button type="submit" class="btn btn-danger" id="delete-account-btn" data-testid="<%= tid('delete-account-button') %>">
|
|
37
|
+
Delete account
|
|
38
|
+
</button>
|
|
39
|
+
</form>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<%- include('partials/footer') %>
|