diff --git a/backend/src/routes/cartCheckout.ts b/backend/src/routes/cartCheckout.ts index b585a29..ec708ed 100644 --- a/backend/src/routes/cartCheckout.ts +++ b/backend/src/routes/cartCheckout.ts @@ -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()}`); 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 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' }); } catch (err) { await client.query('ROLLBACK'); diff --git a/backend/tests/integration/favorites.integration.test.ts b/backend/tests/integration/favorites.integration.test.ts index 24e3b56..58aa054 100644 --- a/backend/tests/integration/favorites.integration.test.ts +++ b/backend/tests/integration/favorites.integration.test.ts @@ -7,6 +7,7 @@ jest.mock('../../src/mailer', () => ({ sendMail: jest.fn().mockResolvedValue(undefined) })); import { sendMail } from '../../src/mailer'; +import { notifyFavoritersOfSale } from '../../src/favoriteAlerts'; const sentMail = sendMail as jest.MockedFunction; beforeEach(async () => { @@ -168,36 +169,69 @@ describe('notifying when a favorited item sells', () => { 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, + itemId: number, + buyerId: number | null + ) { + await buyViaDemo(agent, itemId); + await notifyFavoritersOfSale([itemId], buyerId); + } + it('emails a favoriter who opted in', async () => { const itemId = await createItem('Wanted item'); const { agent: watcher } = await register('watcher@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('buyer@example.com'); - await buyViaDemo(buyer, itemId); + const { agent: buyer, id: buyerId } = await register('buyer@example.com'); + await buyThenNotify(buyer, itemId, buyerId); 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 () => { const itemId = await createItem('Wanted item'); const { agent: watcher } = await register('silent@example.com'); await watcher.post(`/api/customers/me/favorites/${itemId}`); - const { agent: buyer } = await register('buyer2@example.com'); - await buyViaDemo(buyer, itemId); + const { agent: buyer, id: buyerId } = await register('buyer2@example.com'); + await buyThenNotify(buyer, itemId, buyerId); expect(soldNotificationsTo()).toEqual([]); }); it('does not tell the buyer their own purchase is unavailable', async () => { 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.put('/api/customers/me/favorite-alerts').send({ enabled: true }); - await buyViaDemo(buyer, itemId); + await buyThenNotify(buyer, itemId, buyerId); 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 }); } - 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.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']); });