Merge branch 'main' into feature/202-sql-invariant
This commit is contained in:
@@ -246,9 +246,20 @@ router.post('/demo/purchase', requireCustomer, asyncRoute(async (req: Request, r
|
|||||||
const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`);
|
const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`);
|
||||||
if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); }
|
if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); }
|
||||||
|
|
||||||
const sold = await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
|
await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
|
||||||
|
// Deliberately no notifyFavoritersOfSale here, unlike the PayPal capture and
|
||||||
|
// webhook paths above. A demo purchase is not a sale. The item really is
|
||||||
|
// marked sold, so the storefront stays truthful about availability, but the
|
||||||
|
// `favoriteSold` copy says the item "has been sold to another customer" and
|
||||||
|
// "will not be restocked" — and both are false when nobody bought anything.
|
||||||
|
//
|
||||||
|
// This is the only outbound consequence a demo purchase has. Everything else
|
||||||
|
// it does is visible to the person who clicked, who has been told it is a
|
||||||
|
// demo (#195, #203); these recipients never saw the cart and have no way to
|
||||||
|
// know. While production runs the demo interim (#191) they are real
|
||||||
|
// customers on real SMTP. See #206.
|
||||||
res.json({ status: 'completed' });
|
res.json({ status: 'completed' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ jest.mock('../../src/mailer', () => ({
|
|||||||
sendMail: jest.fn().mockResolvedValue(undefined)
|
sendMail: jest.fn().mockResolvedValue(undefined)
|
||||||
}));
|
}));
|
||||||
import { sendMail } from '../../src/mailer';
|
import { sendMail } from '../../src/mailer';
|
||||||
|
import { notifyFavoritersOfSale } from '../../src/favoriteAlerts';
|
||||||
const sentMail = sendMail as jest.MockedFunction<typeof sendMail>;
|
const sentMail = sendMail as jest.MockedFunction<typeof sendMail>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -168,36 +169,69 @@ describe('notifying when a favorited item sells', () => {
|
|||||||
expect(purchase.status).toBeLessThan(400);
|
expect(purchase.status).toBeLessThan(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The demo route deliberately notifies nobody (#206) and the PayPal path has
|
||||||
|
// no integration coverage, so the tests below call the notifier the way the
|
||||||
|
// PayPal capture and webhook routes do — after the purchase, with the sold
|
||||||
|
// ids and the buyer. That keeps them about *who* gets told, which is what
|
||||||
|
// they were testing all along; whether the demo route itself notifies is
|
||||||
|
// asserted separately, above.
|
||||||
|
async function buyThenNotify(
|
||||||
|
agent: ReturnType<typeof request.agent>,
|
||||||
|
itemId: number,
|
||||||
|
buyerId: number | null
|
||||||
|
) {
|
||||||
|
await buyViaDemo(agent, itemId);
|
||||||
|
await notifyFavoritersOfSale([itemId], buyerId);
|
||||||
|
}
|
||||||
|
|
||||||
it('emails a favoriter who opted in', async () => {
|
it('emails a favoriter who opted in', async () => {
|
||||||
const itemId = await createItem('Wanted item');
|
const itemId = await createItem('Wanted item');
|
||||||
const { agent: watcher } = await register('watcher@example.com');
|
const { agent: watcher } = await register('watcher@example.com');
|
||||||
await watcher.post(`/api/customers/me/favorites/${itemId}`);
|
await watcher.post(`/api/customers/me/favorites/${itemId}`);
|
||||||
await watcher.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
await watcher.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
||||||
|
|
||||||
const { agent: buyer } = await register('buyer@example.com');
|
const { agent: buyer, id: buyerId } = await register('buyer@example.com');
|
||||||
await buyViaDemo(buyer, itemId);
|
await buyThenNotify(buyer, itemId, buyerId);
|
||||||
|
|
||||||
expect(soldNotificationsTo()).toEqual(['watcher@example.com']);
|
expect(soldNotificationsTo()).toEqual(['watcher@example.com']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A demo purchase is not a sale. The item really is marked sold, so the
|
||||||
|
// storefront is telling the truth about availability, but nobody bought
|
||||||
|
// anything and nobody is shipping anything — and while production runs the
|
||||||
|
// demo interim (#191) this mail reaches real favoriters through real SMTP,
|
||||||
|
// telling them an item "has been sold to another customer" and "will not be
|
||||||
|
// restocked". Both are false. See #206.
|
||||||
|
it('sends nothing when the purchase was a demo', async () => {
|
||||||
|
const itemId = await createItem('Demo bought');
|
||||||
|
const { agent: watcher } = await register('watcher-demo@example.com');
|
||||||
|
await watcher.post(`/api/customers/me/favorites/${itemId}`);
|
||||||
|
await watcher.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
||||||
|
|
||||||
|
const { agent: buyer } = await register('demo-buyer@example.com');
|
||||||
|
await buyViaDemo(buyer, itemId);
|
||||||
|
|
||||||
|
expect(soldNotificationsTo()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not email a favoriter who never opted in', async () => {
|
it('does not email a favoriter who never opted in', async () => {
|
||||||
const itemId = await createItem('Wanted item');
|
const itemId = await createItem('Wanted item');
|
||||||
const { agent: watcher } = await register('silent@example.com');
|
const { agent: watcher } = await register('silent@example.com');
|
||||||
await watcher.post(`/api/customers/me/favorites/${itemId}`);
|
await watcher.post(`/api/customers/me/favorites/${itemId}`);
|
||||||
|
|
||||||
const { agent: buyer } = await register('buyer2@example.com');
|
const { agent: buyer, id: buyerId } = await register('buyer2@example.com');
|
||||||
await buyViaDemo(buyer, itemId);
|
await buyThenNotify(buyer, itemId, buyerId);
|
||||||
|
|
||||||
expect(soldNotificationsTo()).toEqual([]);
|
expect(soldNotificationsTo()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not tell the buyer their own purchase is unavailable', async () => {
|
it('does not tell the buyer their own purchase is unavailable', async () => {
|
||||||
const itemId = await createItem('Self bought');
|
const itemId = await createItem('Self bought');
|
||||||
const { agent: buyer } = await register('selfbuy@example.com');
|
const { agent: buyer, id: buyerId } = await register('selfbuy@example.com');
|
||||||
await buyer.post(`/api/customers/me/favorites/${itemId}`);
|
await buyer.post(`/api/customers/me/favorites/${itemId}`);
|
||||||
await buyer.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
await buyer.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
||||||
|
|
||||||
await buyViaDemo(buyer, itemId);
|
await buyThenNotify(buyer, itemId, buyerId);
|
||||||
|
|
||||||
expect(soldNotificationsTo()).toEqual([]);
|
expect(soldNotificationsTo()).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -210,10 +244,10 @@ describe('notifying when a favorited item sells', () => {
|
|||||||
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { agent: buyer } = await register('c@example.com');
|
const { agent: buyer, id: buyerId } = await register('c@example.com');
|
||||||
await buyer.post(`/api/customers/me/favorites/${itemId}`);
|
await buyer.post(`/api/customers/me/favorites/${itemId}`);
|
||||||
await buyer.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
await buyer.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
||||||
await buyViaDemo(buyer, itemId);
|
await buyThenNotify(buyer, itemId, buyerId);
|
||||||
|
|
||||||
expect(soldNotificationsTo().sort()).toEqual(['a@example.com', 'b@example.com']);
|
expect(soldNotificationsTo().sort()).toEqual(['a@example.com', 'b@example.com']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,8 +57,10 @@
|
|||||||
# (#190). Everything else is written out, so there is one place to look and one
|
# (#190). Everything else is written out, so there is one place to look and one
|
||||||
# thing that can be wrong.
|
# thing that can be wrong.
|
||||||
#
|
#
|
||||||
# Required stack environment variables. All must be set in Portainer for this
|
# The stack environment variables this file reads. Each entry says whether it is
|
||||||
# stack. All are secrets except DEMO_MODE:
|
# required and when — there is no blanket rule, because three are unused while
|
||||||
|
# DEMO_MODE is `true`, three are optional, and DEMO_MODE and SMTP_FROM are not
|
||||||
|
# secrets at all:
|
||||||
#
|
#
|
||||||
# DEMO_MODE `true` or `false`, exactly. Whether real payments are
|
# DEMO_MODE `true` or `false`, exactly. Whether real payments are
|
||||||
# taken. Not a secret — it is here rather than written
|
# taken. Not a secret — it is here rather than written
|
||||||
|
|||||||
@@ -60,9 +60,13 @@ Everything here is lost when the stack is deleted, and the rollback in step 8 is
|
|||||||
|
|
||||||
**The stack name**, exactly as Portainer shows it. If it is not `redefined-designs`, note that — the new stack must be created with that name, because the stack name becomes the compose project name and reusing QA's would make Compose reconcile the two against each other.
|
**The stack name**, exactly as Portainer shows it. If it is not `redefined-designs`, note that — the new stack must be created with that name, because the stack name becomes the compose project name and reusing QA's would make Compose reconcile the two against each other.
|
||||||
|
|
||||||
**Every stack environment variable, name and value.** They belong to the stack, and deleting it discards them. This is the step whose omission is felt hardest: the compose file interpolates `DEMO_MODE`, `DB_PASSWORD`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `ADMIN_GATE_SECRET`, `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET` and `PAYPAL_WEBHOOK_ID`, and an unset one substitutes to an empty string rather than failing. A missing `DB_PASSWORD` cannot authenticate against its own data directory, and none of this is recoverable from anything in this repository.
|
**Every stack environment variable, name and value.** They belong to the stack, and deleting it discards them. This is the step whose omission is felt hardest. The compose file interpolates thirteen names — `DEMO_MODE`, `DB_PASSWORD`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `ADMIN_GATE_SECRET`, `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `USPS_CLIENT_ID`, `USPS_CLIENT_SECRET`, `UPLOADS_BASE_URL` and `BACKUP_PASSPHRASE` — and an unset one substitutes to an empty string rather than failing. None of it is recoverable from anything in this repository. Take everything the stack holds rather than working from this list; it is here to say how much there is, and it is checked against the file rather than from memory.
|
||||||
|
|
||||||
`DEMO_MODE` is the odd one out and the easiest to miss, because it is the only one that is not a secret. It must be exactly `true` or `false`. There is no default in the compose file, deliberately (#190) — a default would decide whether the shop takes money on the operator's behalf, silently, whichever way it pointed — so an unset `DEMO_MODE` refuses to boot rather than guessing. Production is `true` for now, the interim from #191; setting it to `false` is what restores real payments, and doing that without all three PayPal secrets present crash-loops the container.
|
`USPS_CLIENT_ID` and `USPS_CLIENT_SECRET` deserve naming because losing them is the one failure here that is completely silent. Address validation is skipped when they are empty rather than failing, so checkout keeps working and quietly stops validating addresses. Nothing in step 7 catches it, and there is no crash loop to notice.
|
||||||
|
|
||||||
|
A missing `DB_PASSWORD` does not fail the way you would expect either: the app never reaches a connection attempt. It refuses at boot, and the message names `PGPASSWORD` rather than the variable you set, because the compose file injects it as `PGPASSWORD=${DB_PASSWORD}`. Grep the log for the name in the error, not the name in Portainer.
|
||||||
|
|
||||||
|
`DEMO_MODE` is the odd one out and the easiest to miss, because it is the only one that is a setting rather than a credential. It must be exactly `true` or `false`. There is no default in the compose file, deliberately (#190) — a default would decide whether the shop takes money on the operator's behalf, silently, whichever way it pointed — so an unset `DEMO_MODE` refuses to boot rather than guessing. Production is `true` for now, the interim from #191; setting it to `false` is what restores real payments, and doing that without all three PayPal secrets present crash-loops the container.
|
||||||
|
|
||||||
Copy them somewhere before you delete anything.
|
Copy them somewhere before you delete anything.
|
||||||
|
|
||||||
@@ -221,14 +225,28 @@ Migration output must appear *before* `listening on 3000`, and `listening on 300
|
|||||||
|
|
||||||
**What a crash loop looks like, and it is the likeliest outcome of a missed step 2.** A `[config] refusing to start` block, then the whole boot sequence again, repeating.
|
**What a crash loop looks like, and it is the likeliest outcome of a missed step 2.** A `[config] refusing to start` block, then the whole boot sequence again, repeating.
|
||||||
|
|
||||||
While production is in the demo interim, the likeliest one is `DEMO_MODE` itself:
|
Miss step 2 wholesale and it is two problems, led by a name you never typed:
|
||||||
|
|
||||||
|
```
|
||||||
|
[config] refusing to start — 2 problem(s) with the environment:
|
||||||
|
[config] - PGPASSWORD is required and is not set.
|
||||||
|
[config] - DEMO_MODE is required and must be exactly 'true' or 'false'. It decides whether real payments are taken, so it has to be stated rather than inherited.
|
||||||
|
```
|
||||||
|
|
||||||
|
Carry `DB_PASSWORD` across and only `DEMO_MODE` is left, which is the single likeliest form during the demo interim:
|
||||||
|
|
||||||
```
|
```
|
||||||
[config] refusing to start — 1 problem(s) with the environment:
|
[config] refusing to start — 1 problem(s) with the environment:
|
||||||
[config] - DEMO_MODE is required and must be exactly 'true' or 'false'.
|
[config] - DEMO_MODE is required and must be exactly 'true' or 'false'. It decides whether real payments are taken, so it has to be stated rather than inherited.
|
||||||
```
|
```
|
||||||
|
|
||||||
The other form appears once `DEMO_MODE` is `false` and real payments are on:
|
A value that was set but mistyped reads differently, and the quotes are the only thing that distinguishes `true ` with a trailing space from `true`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[config] - DEMO_MODE must be exactly 'true' or 'false', but is 'True'. Anything else used to be read as demo mode, which meant a typo here quietly stopped the shop charging anyone.
|
||||||
|
```
|
||||||
|
|
||||||
|
The PayPal form appears once `DEMO_MODE` is `false` and real payments are on:
|
||||||
|
|
||||||
```
|
```
|
||||||
[config] refusing to start — 3 problem(s) with the environment:
|
[config] refusing to start — 3 problem(s) with the environment:
|
||||||
|
|||||||
@@ -38,7 +38,16 @@ const COLUMNS = [
|
|||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (v: string) => <Tag color={STATUS_COLORS[v]}>{v}</Tag>
|
render: (v: string) => <Tag color={STATUS_COLORS[v]}>{v}</Tag>
|
||||||
},
|
},
|
||||||
{ title: 'Processor', dataIndex: 'processor' },
|
{
|
||||||
|
title: 'Processor',
|
||||||
|
dataIndex: 'processor',
|
||||||
|
// `demo` used to render as a raw column value, which is not an explanation:
|
||||||
|
// a customer has no reason to read it as "this did not happen", and the row
|
||||||
|
// was otherwise identical to a real one — real price, `completed` status,
|
||||||
|
// same neutral tag. The cart says it is a demo (#195, #203) for about three
|
||||||
|
// seconds; this is the record they come back to. See #205.
|
||||||
|
render: (v: string) => (v === 'demo' ? <Tag color="warning">Demo (not charged)</Tag> : v)
|
||||||
|
},
|
||||||
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
|
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -69,6 +78,12 @@ function OrdersBody({ loading, error, orders, onRetry }: BodyProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shown only when there is one to explain. The per-row tag says which order,
|
||||||
|
// this says what it means — a tag reading "Demo" still assumes the reader
|
||||||
|
// knows what a demo order is, and the thing they actually want to know is
|
||||||
|
// whether to expect a parcel.
|
||||||
|
const hasDemoOrder = orders.some(o => o.processor === 'demo');
|
||||||
|
|
||||||
if (orders.length === 0) {
|
if (orders.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" align="center" style={{ width: '100%', marginTop: 24 }}>
|
<Space direction="vertical" align="center" style={{ width: '100%', marginTop: 24 }}>
|
||||||
@@ -79,15 +94,26 @@ function OrdersBody({ loading, error, orders, onRetry }: BodyProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<>
|
||||||
rowKey="id"
|
{hasDemoOrder && (
|
||||||
dataSource={orders}
|
<Alert
|
||||||
pagination={false}
|
type="warning"
|
||||||
// Still correct on a phone. It is no longer compensating for being in a
|
showIcon
|
||||||
// modal narrower than its own content.
|
style={{ marginBottom: 16 }}
|
||||||
scroll={{ x: 'max-content' }}
|
message="Some of these are demo orders"
|
||||||
columns={COLUMNS}
|
description="A demo order is a pretend one: nothing was charged, and nothing will be shipped."
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={orders}
|
||||||
|
pagination={false}
|
||||||
|
// Still correct on a phone. It is no longer compensating for being in a
|
||||||
|
// modal narrower than its own content.
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
columns={COLUMNS}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,32 @@ test.describe('Demo mode says so to the customer', () => {
|
|||||||
await expect(cart.demoNotice).toBeVisible({ timeout: 20000 });
|
await expect(cart.demoNotice).toBeVisible({ timeout: 20000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The toast is three seconds; this is the record the customer comes back to
|
||||||
|
// when they wonder where their item is. It showed `demo` as a raw value under
|
||||||
|
// a "Processor" heading, beside a real price and a neutral `completed` tag —
|
||||||
|
// nothing a customer would read as "this did not happen". See #205.
|
||||||
|
test('order history marks the demo order rather than showing it as a real one', async ({
|
||||||
|
page,
|
||||||
|
customer,
|
||||||
|
cart,
|
||||||
|
orders
|
||||||
|
}) => {
|
||||||
|
const name = `Demo h${uniqueSuffix()}`;
|
||||||
|
const item = await createItem(page.request, { name, price: '80' });
|
||||||
|
expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
|
||||||
|
expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true);
|
||||||
|
|
||||||
|
await cart.goto();
|
||||||
|
await expect(cart.checkoutButton).toBeVisible({ timeout: 20000 });
|
||||||
|
await cart.checkoutButton.click();
|
||||||
|
await expect(page.getByText(/nothing was charged/i)).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
await orders.goto();
|
||||||
|
|
||||||
|
await expect(orders.demoOrderMarker).toBeVisible({ timeout: 20000 });
|
||||||
|
await expect(orders.demoNotice).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('the confirmation says nothing was charged', async ({ page, customer, cart }) => {
|
test('the confirmation says nothing was charged', async ({ page, customer, cart }) => {
|
||||||
const name = `Demo p${uniqueSuffix()}`;
|
const name = `Demo p${uniqueSuffix()}`;
|
||||||
const item = await createItem(page.request, { name, price: '80' });
|
const item = await createItem(page.request, { name, price: '80' });
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export class OrdersPage {
|
|||||||
readonly continueShoppingButton: Locator;
|
readonly continueShoppingButton: Locator;
|
||||||
readonly backToShopButton: Locator;
|
readonly backToShopButton: Locator;
|
||||||
readonly anyDialog: Locator;
|
readonly anyDialog: Locator;
|
||||||
|
readonly demoNotice: Locator;
|
||||||
|
readonly demoOrderMarker: Locator;
|
||||||
|
|
||||||
constructor(private readonly page: Page) {
|
constructor(private readonly page: Page) {
|
||||||
this.heading = page.getByRole('heading', { name: 'Order History' });
|
this.heading = page.getByRole('heading', { name: 'Order History' });
|
||||||
@@ -21,6 +23,8 @@ export class OrdersPage {
|
|||||||
this.continueShoppingButton = page.getByRole('button', { name: 'Continue Shopping' });
|
this.continueShoppingButton = page.getByRole('button', { name: 'Continue Shopping' });
|
||||||
this.backToShopButton = page.getByRole('button', { name: 'Back to Shop' });
|
this.backToShopButton = page.getByRole('button', { name: 'Back to Shop' });
|
||||||
this.anyDialog = page.getByRole('dialog');
|
this.anyDialog = page.getByRole('dialog');
|
||||||
|
this.demoNotice = page.getByText('Some of these are demo orders');
|
||||||
|
this.demoOrderMarker = page.getByText('Demo (not charged)');
|
||||||
}
|
}
|
||||||
|
|
||||||
async goto(): Promise<void> {
|
async goto(): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user