Feature/106 customer first last name #109

Merged
bermudalamb merged 2 commits from feature/106-customer-first-last-name into main 2026-08-21 18:42:00 -05:00
20 changed files with 253 additions and 38 deletions
@@ -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;
`);
};
+4 -2
View File
@@ -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]
); );
+27 -9
View File
@@ -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]));
})); }));
+4 -4
View File
@@ -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;
} }
@@ -28,7 +28,13 @@ const compose = readFileSync(COMPOSE_PATH, 'utf8');
// A mention inside a comment cannot match, because a comment line starts with #. // A mention inside a comment cannot match, because a comment line starts with #.
function environmentEntries(source: string): Map<string, string> { function environmentEntries(source: string): Map<string, string> {
const entries = new Map<string, string>(); const entries = new Map<string, string>();
for (const line of source.split('\n')) { // The split pattern tolerates carriage returns. A checkout with CRLF line
// endings, which is every fresh clone on Windows, otherwise leaves a stray
// carriage return that the end-of-line anchor below cannot match, and every
// assertion in this file then silently finds nothing at all. That is exactly
// the failure the "parsed some entries" case exists to catch, and it is how
// this guard was found to be broken.
for (const line of source.split(/\r?\n/)) {
const match = /^\s+- ([A-Z_0-9]+)=(.*)$/.exec(line); const match = /^\s+- ([A-Z_0-9]+)=(.*)$/.exec(line);
if (match) { if (match) {
entries.set(match[1], match[2].trim()); entries.set(match[1], match[2].trim());
+14 -3
View File
@@ -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" />
+12 -3
View File
@@ -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));
} }
+2
View File
@@ -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 });
+17
View File
@@ -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();
+4
View File
@@ -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