fix(email): stop a demo purchase telling real customers an item sold (#206)
Linting / lint (pull_request) Successful in 1m58s
SonarQube Analysis / sonarqube (pull_request) Failing after 17m38s

A demo purchase called `notifyFavoritersOfSale`, which mails everyone who favorited the item through production's configured SMTP: "An item you favorited has been sold to another customer, so it is no longer available… this one will not be restocked."

Nobody bought it and nobody is shipping anything, so both halves are false. It is also the only outbound consequence a demo purchase has — everything #195 and #203 fixed is on screen, in front of the person who clicked and who has now been told it is a demo. These recipients never saw the cart. They just get told something they cared about is gone, and while production runs the demo interim (#191) they are real customers on real SMTP.

The demo route no longer notifies. The PayPal capture and webhook paths are untouched, because those are sales.

The item is still marked `sold`, so the storefront stays truthful about availability and the favoriter who goes looking finds what the database says. Only the claim that somebody bought it goes away. That a demo purchase permanently consumes real production inventory is a larger question than this issue and is left alone.

Removing the call broke two tests and quietly hollowed out three more, which is the more interesting half of this change. Five tests in `favorites.integration.test.ts` used the demo purchase as a convenient way to make a sale happen; with the notification gone, the two asserting mail *is* sent failed, and the three asserting it is *not* sent would have passed for the wrong reason for ever.

They were always about who gets told rather than about the demo route, so they now call the notifier the way the PayPal routes do — after the purchase, with the sold ids and the buyer. `buyThenNotify` says so at the point of use. Route-level coverage is unaffected: the admin mark-sold path already had its own test, and the new test asserts the demo route notifies nobody.

Both halves were mutation-tested rather than assumed. The new test fails without the fix. Dropping the buyer exclusion from `collectFavoriteRecipients` fails "does not tell the buyer their own purchase is unavailable" and "emails every opted-in favoriter except the buyer" — so the restored tests are guarding the logic again rather than passing on an empty inbox.

Verified: 255 integration tests pass (the suite needs `--runInBand`; these share one database), 278 unit tests pass, backend build clean.

Closes #206

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 12:16:29 -05:00
co-authored by Claude Opus 5
parent f0bdc590c9
commit a4cc6d0cbf
2 changed files with 55 additions and 10 deletions
+13 -2
View File
@@ -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']);
}); });