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) => {
|
||||
const { rows } = await pool.query(`
|
||||
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,
|
||||
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
|
||||
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) => {
|
||||
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`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
@@ -39,7 +39,11 @@ async function createSession(customerId: number): Promise<string> {
|
||||
interface CustomerRow {
|
||||
id: number;
|
||||
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;
|
||||
marketing_consent: boolean;
|
||||
favorite_alerts: boolean;
|
||||
@@ -50,7 +54,8 @@ function publicCustomer(c: CustomerRow) {
|
||||
return {
|
||||
id: c.id,
|
||||
email: c.email,
|
||||
name: c.name,
|
||||
first_name: c.first_name,
|
||||
last_name: c.last_name,
|
||||
email_verified: c.email_verified,
|
||||
marketing_consent: c.marketing_consent,
|
||||
favorite_alerts: c.favorite_alerts,
|
||||
@@ -59,10 +64,20 @@ function publicCustomer(c: CustomerRow) {
|
||||
}
|
||||
|
||||
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) {
|
||||
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 { 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' });
|
||||
@@ -72,10 +87,10 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
const consent = !!marketingConsent;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO customers (email, password_hash, name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
`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, $8) RETURNING *`,
|
||||
[
|
||||
normalizedEmail, passwordHash, name || null,
|
||||
normalizedEmail, passwordHash, first, last,
|
||||
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
||||
unsubscribeToken
|
||||
]
|
||||
@@ -285,11 +300,14 @@ router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response
|
||||
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) => {
|
||||
const { name } = req.body;
|
||||
const { firstName, lastName } = req.body;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE customers SET name = $1 WHERE id = $2 RETURNING *`,
|
||||
[name || null, req.customerId]
|
||||
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
|
||||
[String(firstName ?? '').trim() || null, String(lastName ?? '').trim() || null, req.customerId]
|
||||
);
|
||||
res.json(publicCustomer(rows[0]));
|
||||
}));
|
||||
|
||||
@@ -23,7 +23,7 @@ async function sweepExpiredCarts(): Promise<void> {
|
||||
async function sendCartReminders(): Promise<void> {
|
||||
try {
|
||||
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
|
||||
JOIN carts ca ON ca.id = ci.cart_id
|
||||
JOIN customers c ON c.id = ca.customer_id
|
||||
@@ -33,9 +33,9 @@ async function sendCartReminders(): Promise<void> {
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ async function sendCartReminders(): Promise<void> {
|
||||
await sendMail(
|
||||
email,
|
||||
'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>
|
||||
<ul>${itemList}</ul>
|
||||
<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) {
|
||||
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]);
|
||||
return { agent, id: rows[0].id as number };
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ afterAll(async () => {
|
||||
|
||||
async function registerAndGetAgent(email: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,83 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
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({
|
||||
email: 'jane@example.com',
|
||||
email: 'nofirst@example.com',
|
||||
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.body.email).toBe('jane@example.com');
|
||||
@@ -26,7 +98,7 @@ describe('POST /api/customers/register', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
password: 'supersecret123',
|
||||
marketingConsent: true
|
||||
@@ -35,11 +107,11 @@ describe('POST /api/customers/register', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
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',
|
||||
password: 'anotherpassword'
|
||||
});
|
||||
@@ -47,7 +119,7 @@ describe('POST /api/customers/register', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
password: '123'
|
||||
});
|
||||
@@ -55,7 +127,7 @@ describe('POST /api/customers/register', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
password: 'supersecret123'
|
||||
});
|
||||
@@ -66,7 +138,7 @@ describe('POST /api/customers/register', () => {
|
||||
describe('session-gated routes', () => {
|
||||
it('logs in and can access /me with the returned session cookie', async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post('/api/customers/register').send({
|
||||
await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
||||
email: 'login-test@example.com',
|
||||
password: 'supersecret123'
|
||||
});
|
||||
@@ -81,7 +153,7 @@ describe('session-gated routes', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
password: 'supersecret123'
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ const PASSWORD = 'supersecret123';
|
||||
|
||||
async function register(email: string) {
|
||||
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);
|
||||
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
|
||||
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.
|
||||
const res = await request(app)
|
||||
.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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ const PASSWORD = 'supersecret123';
|
||||
|
||||
async function register(email: string) {
|
||||
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]);
|
||||
return { agent, id: rows[0].id as number };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const PASSWORD = 'supersecret123';
|
||||
|
||||
async function register(email: string) {
|
||||
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);
|
||||
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 #.
|
||||
function environmentEntries(source: string): 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);
|
||||
if (match) {
|
||||
entries.set(match[1], match[2].trim());
|
||||
|
||||
@@ -76,12 +76,23 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
layout="vertical"
|
||||
onFinish={(values) =>
|
||||
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">
|
||||
<Input autoComplete="name" />
|
||||
<Form.Item
|
||||
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 name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input autoComplete="email" />
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export interface Customer {
|
||||
id: number;
|
||||
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;
|
||||
marketing_consent: boolean;
|
||||
favorite_alerts: boolean;
|
||||
@@ -25,11 +28,17 @@ async function handle<T>(res: Response): Promise<T> {
|
||||
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', {
|
||||
method: 'POST',
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ async function registerCustomer(page: Page): Promise<string> {
|
||||
const email = uniqueEmail();
|
||||
await page.goto('/register');
|
||||
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.getByRole('button', { name: 'Create account' }).click();
|
||||
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) {
|
||||
await page.goto('/register');
|
||||
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.getByRole('button', { name: 'Create account' }).click();
|
||||
// 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.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.getByRole('button', { name: 'Create account' }).click();
|
||||
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`;
|
||||
await page.goto('/register');
|
||||
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.getByRole('button', { name: 'Create account' }).click();
|
||||
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) {
|
||||
await page.goto('/register');
|
||||
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.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
||||
@@ -27,6 +29,21 @@ async function openAccount(page: Page) {
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
await page.goto('/register');
|
||||
await expect(page.getByRole('checkbox')).not.toBeChecked();
|
||||
|
||||
@@ -30,6 +30,8 @@ test.beforeAll(async ({ playwright }) => {
|
||||
async function register(page: Page) {
|
||||
await page.goto('/register');
|
||||
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.getByRole('button', { name: 'Create account' }).click();
|
||||
// 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 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.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ test.beforeAll(async ({ playwright }) => {
|
||||
async function register(page: Page, email: string) {
|
||||
await page.goto('/register');
|
||||
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.getByRole('button', { name: 'Create account' }).click();
|
||||
// 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();
|
||||
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').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) {
|
||||
await page.goto('/register');
|
||||
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.getByRole('button', { name: 'Create account' }).click();
|
||||
// Registering now closes the auth modal and returns to the page behind it, so
|
||||
|
||||
Reference in New Issue
Block a user