Feature/106 customer first last name #109
@@ -0,0 +1,62 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
-- Emails greet customers, and a single 'name' column only allows the formal
|
||||||
|
-- whole name: "Hi Thom Lamb," rather than "Hi Thom,". Splitting it is what
|
||||||
|
-- makes an informal greeting possible.
|
||||||
|
--
|
||||||
|
-- Both columns are nullable even though registration now requires them. The
|
||||||
|
-- requirement is enforced in the route, where a missing field can produce a
|
||||||
|
-- 400 naming it. Marking these NOT NULL would mean backfilling legacy rows
|
||||||
|
-- with empty strings, which asserts that every customer has a name — and
|
||||||
|
-- that is not true of anyone who registered while the field was optional.
|
||||||
|
-- The table should record what is actually the case.
|
||||||
|
ALTER TABLE customers ADD COLUMN IF NOT EXISTS first_name TEXT;
|
||||||
|
ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_name TEXT;
|
||||||
|
|
||||||
|
-- The lossy part, and there is no version of this that is not.
|
||||||
|
--
|
||||||
|
-- Splitting on the first space is right for "Thom Lamb" and wrong for
|
||||||
|
-- "Mary Jane Smith", who ends up with a last name of "Jane Smith". Names do
|
||||||
|
-- not reliably divide into two parts at all. This was chosen over leaving
|
||||||
|
-- the columns empty because there is currently no way for a customer to
|
||||||
|
-- correct their own name — PUT /api/customers/me exists but nothing calls
|
||||||
|
-- it — so empty would mean permanently unpersonalised for everyone who
|
||||||
|
-- registered before this.
|
||||||
|
--
|
||||||
|
-- Treat backfilled values as a best guess rather than as data the customer
|
||||||
|
-- gave you in this shape.
|
||||||
|
UPDATE customers
|
||||||
|
SET first_name = CASE
|
||||||
|
WHEN position(' ' in btrim(name)) > 0 THEN split_part(btrim(name), ' ', 1)
|
||||||
|
ELSE btrim(name)
|
||||||
|
END,
|
||||||
|
last_name = CASE
|
||||||
|
WHEN position(' ' in btrim(name)) > 0
|
||||||
|
THEN btrim(substring(btrim(name) from position(' ' in btrim(name)) + 1))
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
WHERE name IS NOT NULL AND btrim(name) <> '';
|
||||||
|
|
||||||
|
-- Dropped rather than kept alongside. Two columns describing the same fact
|
||||||
|
-- drift, and the new pair is now the only place a name lives.
|
||||||
|
ALTER TABLE customers DROP COLUMN IF EXISTS name;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE customers ADD COLUMN IF NOT EXISTS name TEXT;
|
||||||
|
|
||||||
|
-- Rejoins the parts. Not a perfect inverse of the split above — a name that
|
||||||
|
-- was mangled on the way in stays mangled on the way out — but it restores
|
||||||
|
-- a usable whole name rather than leaving the column empty.
|
||||||
|
UPDATE customers
|
||||||
|
SET name = btrim(concat_ws(' ', first_name, last_name))
|
||||||
|
WHERE first_name IS NOT NULL OR last_name IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE customers SET name = NULL WHERE name = '';
|
||||||
|
|
||||||
|
ALTER TABLE customers DROP COLUMN IF EXISTS first_name;
|
||||||
|
ALTER TABLE customers DROP COLUMN IF EXISTS last_name;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -7,7 +7,8 @@ const router = Router();
|
|||||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at, c.disabled_at,
|
c.id, c.email, nullif(btrim(concat_ws(' ', c.first_name, c.last_name)), '') AS name,
|
||||||
|
c.email_verified, c.marketing_consent, c.created_at, c.disabled_at,
|
||||||
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count,
|
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count,
|
||||||
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
|
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
|
||||||
MAX(o.created_at) AS last_order_at,
|
MAX(o.created_at) AS last_order_at,
|
||||||
@@ -141,7 +142,8 @@ router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res
|
|||||||
|
|
||||||
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const { rows: customerRows } = await pool.query(
|
const { rows: customerRows } = await pool.query(
|
||||||
`SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at
|
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
|
||||||
|
email_verified, marketing_consent, marketing_consent_at, created_at
|
||||||
FROM customers WHERE id = $1`,
|
FROM customers WHERE id = $1`,
|
||||||
[req.params.id]
|
[req.params.id]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ async function createSession(customerId: number): Promise<string> {
|
|||||||
interface CustomerRow {
|
interface CustomerRow {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
name: string | null;
|
// Nullable despite registration requiring both, because customers who
|
||||||
|
// registered while the field was optional genuinely have no name. The
|
||||||
|
// requirement is enforced at registration, not asserted by the schema.
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
marketing_consent: boolean;
|
marketing_consent: boolean;
|
||||||
favorite_alerts: boolean;
|
favorite_alerts: boolean;
|
||||||
@@ -50,7 +54,8 @@ function publicCustomer(c: CustomerRow) {
|
|||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
email: c.email,
|
email: c.email,
|
||||||
name: c.name,
|
first_name: c.first_name,
|
||||||
|
last_name: c.last_name,
|
||||||
email_verified: c.email_verified,
|
email_verified: c.email_verified,
|
||||||
marketing_consent: c.marketing_consent,
|
marketing_consent: c.marketing_consent,
|
||||||
favorite_alerts: c.favorite_alerts,
|
favorite_alerts: c.favorite_alerts,
|
||||||
@@ -59,10 +64,20 @@ function publicCustomer(c: CustomerRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const { email, password, name, marketingConsent } = req.body;
|
const { email, password, firstName, lastName, marketingConsent } = req.body;
|
||||||
if (!email || !isValidEmail(String(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' });
|
return res.status(400).json({ error: 'valid email and password (min 8 chars) required' });
|
||||||
}
|
}
|
||||||
|
// Named individually rather than as one "name is required", so a form that
|
||||||
|
// filled one field and not the other is told which.
|
||||||
|
const first = String(firstName ?? '').trim();
|
||||||
|
const last = String(lastName ?? '').trim();
|
||||||
|
if (!first) {
|
||||||
|
return res.status(400).json({ error: 'first name is required' });
|
||||||
|
}
|
||||||
|
if (!last) {
|
||||||
|
return res.status(400).json({ error: 'last name is required' });
|
||||||
|
}
|
||||||
const normalizedEmail = String(email).toLowerCase().trim();
|
const normalizedEmail = String(email).toLowerCase().trim();
|
||||||
const { rows: existing } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]);
|
const { rows: existing } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]);
|
||||||
if (existing.length) return res.status(409).json({ error: 'an account with this email already exists' });
|
if (existing.length) return res.status(409).json({ error: 'an account with this email already exists' });
|
||||||
@@ -72,10 +87,10 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
|||||||
const consent = !!marketingConsent;
|
const consent = !!marketingConsent;
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO customers (email, password_hash, name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
|
`INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||||
[
|
[
|
||||||
normalizedEmail, passwordHash, name || null,
|
normalizedEmail, passwordHash, first, last,
|
||||||
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
||||||
unsubscribeToken
|
unsubscribeToken
|
||||||
]
|
]
|
||||||
@@ -285,11 +300,14 @@ router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response
|
|||||||
res.json(publicCustomer(rows[0]));
|
res.json(publicCustomer(rows[0]));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Kept in step with registration for consistency. Note nothing in the frontend
|
||||||
|
// calls this today — the account page has no name editing — so this is API
|
||||||
|
// surface without a caller rather than a path in use.
|
||||||
router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||||
const { name } = req.body;
|
const { firstName, lastName } = req.body;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`UPDATE customers SET name = $1 WHERE id = $2 RETURNING *`,
|
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
|
||||||
[name || null, req.customerId]
|
[String(firstName ?? '').trim() || null, String(lastName ?? '').trim() || null, req.customerId]
|
||||||
);
|
);
|
||||||
res.json(publicCustomer(rows[0]));
|
res.json(publicCustomer(rows[0]));
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async function sweepExpiredCarts(): Promise<void> {
|
|||||||
async function sendCartReminders(): Promise<void> {
|
async function sendCartReminders(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT c.email, c.name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
|
SELECT c.email, c.first_name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
|
||||||
FROM cart_items ci
|
FROM cart_items ci
|
||||||
JOIN carts ca ON ca.id = ci.cart_id
|
JOIN carts ca ON ca.id = ci.cart_id
|
||||||
JOIN customers c ON c.id = ca.customer_id
|
JOIN customers c ON c.id = ca.customer_id
|
||||||
@@ -33,9 +33,9 @@ async function sendCartReminders(): Promise<void> {
|
|||||||
AND ci.expires_at > now()
|
AND ci.expires_at > now()
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const byEmail = new Map<string, { name: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
|
const byEmail = new Map<string, { firstName: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!byEmail.has(row.email)) byEmail.set(row.email, { name: row.name, items: [] });
|
if (!byEmail.has(row.email)) byEmail.set(row.email, { firstName: row.first_name, items: [] });
|
||||||
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
|
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ async function sendCartReminders(): Promise<void> {
|
|||||||
await sendMail(
|
await sendMail(
|
||||||
email,
|
email,
|
||||||
'Items waiting in your cart',
|
'Items waiting in your cart',
|
||||||
`<p>Hi${data.name ? ' ' + data.name : ''},</p>
|
`<p>Hi${data.firstName ? ' ' + data.firstName : ''},</p>
|
||||||
<p>You still have items in your cart at Redefined Designs:</p>
|
<p>You still have items in your cart at Redefined Designs:</p>
|
||||||
<ul>${itemList}</ul>
|
<ul>${itemList}</ul>
|
||||||
<p><a href="${process.env.PUBLIC_URL}/cart">View your cart</a> before your reservation expires.</p>`
|
<p><a href="${process.env.PUBLIC_URL}/cart">View your cart</a> before your reservation expires.</p>`
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ async function createItem(
|
|||||||
|
|
||||||
async function registerCustomer(email: string) {
|
async function registerCustomer(email: string) {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
await agent.post('/api/customers/register').send({ email, password: 'supersecret123' });
|
await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer', email, password: 'supersecret123' });
|
||||||
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
|
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
|
||||||
return { agent, id: rows[0].id as number };
|
return { agent, id: rows[0].id as number };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
async function registerAndGetAgent(email: string) {
|
async function registerAndGetAgent(email: string) {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
await agent.post('/api/customers/register').send({ email, password: 'supersecret123' });
|
await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer', email, password: 'supersecret123' });
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,83 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/customers/register', () => {
|
describe('POST /api/customers/register', () => {
|
||||||
it('creates an account with marketing consent unchecked by default', async () => {
|
// Both names are required from anyone new, so the greeting in every email has
|
||||||
|
// something to use. Refused individually rather than as one "name required",
|
||||||
|
// so a form that filled one and not the other is told which. See #106.
|
||||||
|
it('refuses a registration with no first name', async () => {
|
||||||
const res = await request(app).post('/api/customers/register').send({
|
const res = await request(app).post('/api/customers/register').send({
|
||||||
email: 'jane@example.com',
|
email: 'nofirst@example.com',
|
||||||
password: 'supersecret123',
|
password: 'supersecret123',
|
||||||
name: 'Jane'
|
lastName: 'Customer'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('first name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a registration with no last name', async () => {
|
||||||
|
const res = await request(app).post('/api/customers/register').send({
|
||||||
|
email: 'nolast@example.com',
|
||||||
|
password: 'supersecret123',
|
||||||
|
firstName: 'Test'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('last name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses names that are only whitespace', async () => {
|
||||||
|
const res = await request(app).post('/api/customers/register').send({
|
||||||
|
email: 'blank@example.com',
|
||||||
|
password: 'supersecret123',
|
||||||
|
firstName: ' ',
|
||||||
|
lastName: 'Customer'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('first name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores both names, trimmed, and returns them', async () => {
|
||||||
|
const res = await request(app).post('/api/customers/register').send({
|
||||||
|
email: 'named@example.com',
|
||||||
|
password: 'supersecret123',
|
||||||
|
firstName: ' Thom ',
|
||||||
|
lastName: ' Lamb '
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.first_name).toBe('Thom');
|
||||||
|
expect(res.body.last_name).toBe('Lamb');
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT first_name, last_name FROM customers WHERE email = $1`,
|
||||||
|
['named@example.com']
|
||||||
|
);
|
||||||
|
expect(rows[0]).toEqual({ first_name: 'Thom', last_name: 'Lamb' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The account is the customer's own, but the shape returned to them should
|
||||||
|
// not quietly grow — a password hash or a token appearing here is the kind of
|
||||||
|
// thing that goes unnoticed.
|
||||||
|
it('does not return anything beyond the public customer shape', async () => {
|
||||||
|
const res = await request(app).post('/api/customers/register').send({
|
||||||
|
email: 'shape@example.com',
|
||||||
|
password: 'supersecret123',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'Customer'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(res.body).sort()).toEqual([
|
||||||
|
'created_at', 'email', 'email_verified', 'favorite_alerts',
|
||||||
|
'first_name', 'id', 'last_name', 'marketing_consent'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an account with marketing consent unchecked by default', async () => {
|
||||||
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
password: 'supersecret123'
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.email).toBe('jane@example.com');
|
expect(res.body.email).toBe('jane@example.com');
|
||||||
@@ -26,7 +98,7 @@ describe('POST /api/customers/register', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('respects an explicit marketing opt-in', async () => {
|
it('respects an explicit marketing opt-in', async () => {
|
||||||
const res = await request(app).post('/api/customers/register').send({
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||||
email: 'opt-in@example.com',
|
email: 'opt-in@example.com',
|
||||||
password: 'supersecret123',
|
password: 'supersecret123',
|
||||||
marketingConsent: true
|
marketingConsent: true
|
||||||
@@ -35,11 +107,11 @@ describe('POST /api/customers/register', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a duplicate email', async () => {
|
it('rejects a duplicate email', async () => {
|
||||||
await request(app).post('/api/customers/register').send({
|
await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||||
email: 'dupe@example.com',
|
email: 'dupe@example.com',
|
||||||
password: 'supersecret123'
|
password: 'supersecret123'
|
||||||
});
|
});
|
||||||
const res = await request(app).post('/api/customers/register').send({
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||||
email: 'dupe@example.com',
|
email: 'dupe@example.com',
|
||||||
password: 'anotherpassword'
|
password: 'anotherpassword'
|
||||||
});
|
});
|
||||||
@@ -47,7 +119,7 @@ describe('POST /api/customers/register', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a password under 8 characters', async () => {
|
it('rejects a password under 8 characters', async () => {
|
||||||
const res = await request(app).post('/api/customers/register').send({
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||||
email: 'short@example.com',
|
email: 'short@example.com',
|
||||||
password: '123'
|
password: '123'
|
||||||
});
|
});
|
||||||
@@ -55,7 +127,7 @@ describe('POST /api/customers/register', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a malformed email', async () => {
|
it('rejects a malformed email', async () => {
|
||||||
const res = await request(app).post('/api/customers/register').send({
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||||
email: 'not-an-email',
|
email: 'not-an-email',
|
||||||
password: 'supersecret123'
|
password: 'supersecret123'
|
||||||
});
|
});
|
||||||
@@ -66,7 +138,7 @@ describe('POST /api/customers/register', () => {
|
|||||||
describe('session-gated routes', () => {
|
describe('session-gated routes', () => {
|
||||||
it('logs in and can access /me with the returned session cookie', async () => {
|
it('logs in and can access /me with the returned session cookie', async () => {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
await agent.post('/api/customers/register').send({
|
await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||||
email: 'login-test@example.com',
|
email: 'login-test@example.com',
|
||||||
password: 'supersecret123'
|
password: 'supersecret123'
|
||||||
});
|
});
|
||||||
@@ -81,7 +153,7 @@ describe('session-gated routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects login with the wrong password', async () => {
|
it('rejects login with the wrong password', async () => {
|
||||||
await request(app).post('/api/customers/register').send({
|
await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||||
email: 'wrongpass@example.com',
|
email: 'wrongpass@example.com',
|
||||||
password: 'supersecret123'
|
password: 'supersecret123'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const PASSWORD = 'supersecret123';
|
|||||||
|
|
||||||
async function register(email: string) {
|
async function register(email: string) {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
const res = await agent.post('/api/customers/register').send({ email, password: PASSWORD });
|
const res = await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer', email, password: PASSWORD });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
|
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
|
||||||
return { agent, id: rows[0].id as number };
|
return { agent, id: rows[0].id as number };
|
||||||
@@ -160,7 +160,7 @@ describe('disabling a customer', () => {
|
|||||||
// Otherwise disabling is trivially undone by signing up again.
|
// Otherwise disabling is trivially undone by signing up again.
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/customers/register')
|
.post('/api/customers/register')
|
||||||
.send({ email: 'reregister@example.com', password: PASSWORD });
|
.send({ firstName: 'Test', lastName: 'Customer', email: 'reregister@example.com', password: PASSWORD });
|
||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const PASSWORD = 'supersecret123';
|
|||||||
|
|
||||||
async function register(email: string) {
|
async function register(email: string) {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
expect((await agent.post('/api/customers/register').send({ email, password: PASSWORD })).status).toBe(200);
|
expect((await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer', email, password: PASSWORD })).status).toBe(200);
|
||||||
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
|
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
|
||||||
return { agent, id: rows[0].id as number };
|
return { agent, id: rows[0].id as number };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const PASSWORD = 'supersecret123';
|
|||||||
|
|
||||||
async function register(email: string) {
|
async function register(email: string) {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
const res = await agent.post('/api/customers/register').send({ email, password: PASSWORD });
|
const res = await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer', email, password: PASSWORD });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,12 +76,23 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={(values) =>
|
onFinish={(values) =>
|
||||||
submit(() =>
|
submit(() =>
|
||||||
registerCustomer(values.email, values.password, values.name, !!values.marketingConsent)
|
registerCustomer(values.email, values.password, values.firstName, values.lastName, !!values.marketingConsent)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form.Item name="name" label="Name">
|
<Form.Item
|
||||||
<Input autoComplete="name" />
|
name="firstName"
|
||||||
|
label="First name"
|
||||||
|
rules={[{ required: true, message: 'First name is required' }]}
|
||||||
|
>
|
||||||
|
<Input autoComplete="given-name" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="lastName"
|
||||||
|
label="Last name"
|
||||||
|
rules={[{ required: true, message: 'Last name is required' }]}
|
||||||
|
>
|
||||||
|
<Input autoComplete="family-name" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
|
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
|
||||||
<Input autoComplete="email" />
|
<Input autoComplete="email" />
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
export interface Customer {
|
export interface Customer {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
name: string | null;
|
// Nullable because customers who registered before these were required have
|
||||||
|
// neither. Registration demands both from anyone new.
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
marketing_consent: boolean;
|
marketing_consent: boolean;
|
||||||
favorite_alerts: boolean;
|
favorite_alerts: boolean;
|
||||||
@@ -25,11 +28,17 @@ async function handle<T>(res: Response): Promise<T> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerCustomer(email: string, password: string, name: string, marketingConsent: boolean): Promise<Customer> {
|
export function registerCustomer(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
firstName: string,
|
||||||
|
lastName: string,
|
||||||
|
marketingConsent: boolean
|
||||||
|
): Promise<Customer> {
|
||||||
return fetch('/api/customers/register', {
|
return fetch('/api/customers/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password, name, marketingConsent })
|
body: JSON.stringify({ email, password, firstName, lastName, marketingConsent })
|
||||||
}).then(res => handle<Customer>(res));
|
}).then(res => handle<Customer>(res));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ async function registerCustomer(page: Page): Promise<string> {
|
|||||||
const email = uniqueEmail();
|
const email = uniqueEmail();
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const uniqueEmail = () => `disable-${Date.now().toString(36)}${Math.random().toS
|
|||||||
async function register(page: Page, email: string) {
|
async function register(page: Page, email: string) {
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
// Registering now closes the auth modal and returns to the page behind it, so
|
// Registering now closes the auth modal and returns to the page behind it, so
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ async function reserveItem(page: import('@playwright/test').Page, itemName: stri
|
|||||||
|
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await page.getByLabel('Password').fill('supersecret123');
|
await page.getByLabel('Password').fill('supersecret123');
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
||||||
@@ -83,6 +85,8 @@ test.describe('Admin reserved items', () => {
|
|||||||
const email = `idle-${suffix()}@example.com`;
|
const email = `idle-${suffix()}@example.com`;
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await page.getByLabel('Password').fill('supersecret123');
|
await page.getByLabel('Password').fill('supersecret123');
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ function uniqueEmail(): string {
|
|||||||
async function register(page: Page, email: string) {
|
async function register(page: Page, email: string) {
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
||||||
@@ -27,6 +29,21 @@ async function openAccount(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Customer accounts', () => {
|
test.describe('Customer accounts', () => {
|
||||||
|
// Both names are required from anyone new so every email has a first name to
|
||||||
|
// greet with (#106). The server refuses without them; this is the form
|
||||||
|
// refusing first, so nobody gets a round trip to find out.
|
||||||
|
test('will not submit a registration without both names', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
||||||
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('First name is required')).toBeVisible();
|
||||||
|
await expect(page.getByText('Last name is required')).toBeVisible();
|
||||||
|
// Still on the form rather than signed in.
|
||||||
|
await expect(page.getByRole('button', { name: 'My Account' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
|
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await expect(page.getByRole('checkbox')).not.toBeChecked();
|
await expect(page.getByRole('checkbox')).not.toBeChecked();
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ test.beforeAll(async ({ playwright }) => {
|
|||||||
async function register(page: Page) {
|
async function register(page: Page) {
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
// Registering now closes the auth modal and returns to the page behind it, so
|
// Registering now closes the auth modal and returns to the page behind it, so
|
||||||
@@ -134,6 +136,8 @@ test.describe('Filtering the storefront by favorites', () => {
|
|||||||
await expect(prompt).toBeVisible();
|
await expect(prompt).toBeVisible();
|
||||||
|
|
||||||
await prompt.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
await prompt.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await prompt.getByLabel('Password').fill(PASSWORD);
|
await prompt.getByLabel('Password').fill(PASSWORD);
|
||||||
await prompt.getByRole('button', { name: 'Create account' }).click();
|
await prompt.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ test.beforeAll(async ({ playwright }) => {
|
|||||||
async function register(page: Page, email: string) {
|
async function register(page: Page, email: string) {
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
// Registering now closes the auth modal and returns to the page behind it, so
|
// Registering now closes the auth modal and returns to the page behind it, so
|
||||||
@@ -52,6 +54,8 @@ test.describe('Favoriting items', () => {
|
|||||||
|
|
||||||
const email = uniqueEmail();
|
const email = uniqueEmail();
|
||||||
await page.getByRole('dialog').getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('dialog').getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await page.getByRole('dialog').getByLabel('Password').fill(PASSWORD);
|
await page.getByRole('dialog').getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('dialog').getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const uniqueEmail = () => `reset-${Date.now().toString(36)}${Math.random().toStr
|
|||||||
async function register(page: Page, email: string) {
|
async function register(page: Page, email: string) {
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
// Registering now closes the auth modal and returns to the page behind it, so
|
// Registering now closes the auth modal and returns to the page behind it, so
|
||||||
|
|||||||
Reference in New Issue
Block a user