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>
427 lines
17 KiB
TypeScript
427 lines
17 KiB
TypeScript
import request from 'supertest';
|
|
import app from '../../src/app';
|
|
import { pool } from '../../src/db';
|
|
import { resetDb, closeDb } from './setup/testDb';
|
|
|
|
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<typeof sendMail>;
|
|
|
|
beforeEach(async () => {
|
|
await resetDb();
|
|
sentMail.mockClear();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pool.end();
|
|
await closeDb();
|
|
});
|
|
|
|
const PASSWORD = 'supersecret123';
|
|
|
|
async function register(email: string) {
|
|
const agent = request.agent(app);
|
|
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 };
|
|
}
|
|
|
|
async function createItem(name: string) {
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO items (name, price_cents, status) VALUES ($1, 1000, 'available') RETURNING id`,
|
|
[name]
|
|
);
|
|
return rows[0].id as number;
|
|
}
|
|
|
|
// Recipients of the "your favorite sold" mail, by address.
|
|
function soldNotificationsTo(): string[] {
|
|
return sentMail.mock.calls
|
|
.filter(call => String(call[1]).includes('has been sold'))
|
|
.map(call => String(call[0]));
|
|
}
|
|
|
|
// Recipients of the "no longer available" mail sent when an item is withdrawn.
|
|
function removalNotificationsTo(): string[] {
|
|
return sentMail.mock.calls
|
|
.filter(call => String(call[1]).includes('no longer available'))
|
|
.map(call => String(call[0]));
|
|
}
|
|
|
|
describe('favoriting items', () => {
|
|
it('records a favorite and lists it back', async () => {
|
|
const itemId = await createItem('Oak table');
|
|
const { agent } = await register('fav@example.com');
|
|
|
|
expect((await agent.post(`/api/customers/me/favorites/${itemId}`)).status).toBe(201);
|
|
|
|
const res = await agent.get('/api/customers/me/favorites');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.map((f: { item_id: number }) => f.item_id)).toEqual([itemId]);
|
|
});
|
|
|
|
it('is idempotent, so a double click does not error', async () => {
|
|
const itemId = await createItem('Oak table');
|
|
const { agent } = await register('twice@example.com');
|
|
|
|
expect((await agent.post(`/api/customers/me/favorites/${itemId}`)).status).toBe(201);
|
|
expect((await agent.post(`/api/customers/me/favorites/${itemId}`)).status).toBe(201);
|
|
|
|
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM favorites`);
|
|
expect(rows[0].n).toBe(1);
|
|
});
|
|
|
|
it('unfavorites', async () => {
|
|
const itemId = await createItem('Oak table');
|
|
const { agent } = await register('unfav@example.com');
|
|
await agent.post(`/api/customers/me/favorites/${itemId}`);
|
|
|
|
expect((await agent.delete(`/api/customers/me/favorites/${itemId}`)).status).toBe(204);
|
|
expect((await agent.get('/api/customers/me/favorites')).body).toEqual([]);
|
|
});
|
|
|
|
it('requires a signed-in customer', async () => {
|
|
const itemId = await createItem('Oak table');
|
|
expect((await request(app).post(`/api/customers/me/favorites/${itemId}`)).status).toBe(401);
|
|
expect((await request(app).get('/api/customers/me/favorites')).status).toBe(401);
|
|
});
|
|
|
|
it('refuses to favorite an item that does not exist', async () => {
|
|
const { agent } = await register('ghost@example.com');
|
|
expect((await agent.post('/api/customers/me/favorites/999999')).status).toBe(404);
|
|
});
|
|
|
|
it('keeps each customer\'s favorites separate', async () => {
|
|
const itemId = await createItem('Oak table');
|
|
const { agent: mine } = await register('mine@example.com');
|
|
const { agent: theirs } = await register('theirs@example.com');
|
|
|
|
await mine.post(`/api/customers/me/favorites/${itemId}`);
|
|
|
|
expect((await theirs.get('/api/customers/me/favorites')).body).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('favorite alert consent', () => {
|
|
it('is off for a new customer', async () => {
|
|
const { agent } = await register('default@example.com');
|
|
const res = await agent.get('/api/customers/me');
|
|
expect(res.body.favorite_alerts).toBe(false);
|
|
});
|
|
|
|
it('records when it was given and the wording shown', async () => {
|
|
const { agent, id } = await register('optin@example.com');
|
|
|
|
expect((await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true })).status).toBe(200);
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT favorite_alerts, favorite_alerts_at, favorite_alerts_text FROM customers WHERE id = $1`,
|
|
[id]
|
|
);
|
|
expect(rows[0].favorite_alerts).toBe(true);
|
|
expect(rows[0].favorite_alerts_at).toBeTruthy();
|
|
expect(rows[0].favorite_alerts_text).toMatch(/favorited/i);
|
|
});
|
|
|
|
it('can be turned back off', async () => {
|
|
const { agent, id } = await register('optout@example.com');
|
|
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
|
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: false });
|
|
|
|
const { rows } = await pool.query(`SELECT favorite_alerts FROM customers WHERE id = $1`, [id]);
|
|
expect(rows[0].favorite_alerts).toBe(false);
|
|
});
|
|
|
|
it('is independent of the marketing consent', async () => {
|
|
const { agent, id } = await register('separate@example.com');
|
|
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
|
|
|
// Opting into item alerts must not quietly sign anyone up for marketing.
|
|
const { rows } = await pool.query(
|
|
`SELECT marketing_consent, favorite_alerts FROM customers WHERE id = $1`,
|
|
[id]
|
|
);
|
|
expect(rows[0].favorite_alerts).toBe(true);
|
|
expect(rows[0].marketing_consent).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('notifying when a favorited item sells', () => {
|
|
async function buyViaDemo(agent: ReturnType<typeof request.agent>, itemId: number) {
|
|
expect((await agent.post(`/api/cart/items/${itemId}`)).status).toBe(201);
|
|
|
|
// Demo checkout needs a shipping address like any other.
|
|
const address = await agent.post('/api/customers/me/addresses').send({
|
|
fullName: 'Test Buyer',
|
|
addressLine1: '1 Test Street',
|
|
city: 'Austin',
|
|
state: 'TX',
|
|
postalCode: '78701'
|
|
});
|
|
expect(address.status).toBeLessThan(400);
|
|
|
|
const purchase = await agent
|
|
.post('/api/checkout/cart/demo/purchase')
|
|
.send({ shippingAddressId: address.body.address.id });
|
|
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 () => {
|
|
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, 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, 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, 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 buyThenNotify(buyer, itemId, buyerId);
|
|
|
|
expect(soldNotificationsTo()).toEqual([]);
|
|
});
|
|
|
|
it('emails every opted-in favoriter except the buyer', async () => {
|
|
const itemId = await createItem('Popular item');
|
|
for (const email of ['a@example.com', 'b@example.com']) {
|
|
const { agent } = await register(email);
|
|
await agent.post(`/api/customers/me/favorites/${itemId}`);
|
|
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
|
}
|
|
|
|
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 buyThenNotify(buyer, itemId, buyerId);
|
|
|
|
expect(soldNotificationsTo().sort()).toEqual(['a@example.com', 'b@example.com']);
|
|
});
|
|
|
|
it('notifies when an admin marks the item sold', async () => {
|
|
const itemId = await createItem('Marked sold');
|
|
const { agent: watcher } = await register('marked@example.com');
|
|
await watcher.post(`/api/customers/me/favorites/${itemId}`);
|
|
await watcher.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
|
|
|
expect((await request(app).post(`/api/admin/items/${itemId}/mark-sold`)).status).toBe(200);
|
|
|
|
expect(soldNotificationsTo()).toEqual(['marked@example.com']);
|
|
});
|
|
|
|
it('does not notify a disabled customer', async () => {
|
|
const itemId = await createItem('Disabled watcher');
|
|
const { agent: watcher, id } = await register('disabled@example.com');
|
|
await watcher.post(`/api/customers/me/favorites/${itemId}`);
|
|
await watcher.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
|
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
|
|
|
|
await request(app).post(`/api/admin/items/${itemId}/mark-sold`);
|
|
|
|
expect(soldNotificationsTo()).toEqual([]);
|
|
});
|
|
|
|
it('does not notify about an item nobody favorited', async () => {
|
|
const itemId = await createItem('Unloved');
|
|
await request(app).post(`/api/admin/items/${itemId}/mark-sold`);
|
|
expect(soldNotificationsTo()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('notifying when a favorited item is deleted', () => {
|
|
it('emails opted-in favoriters that it is no longer available', async () => {
|
|
const itemId = await createItem('Withdrawn item');
|
|
const { agent } = await register('withdrawn@example.com');
|
|
await agent.post(`/api/customers/me/favorites/${itemId}`);
|
|
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
|
|
|
expect((await request(app).delete(`/api/admin/items/${itemId}`)).status).toBe(204);
|
|
|
|
expect(removalNotificationsTo()).toEqual(['withdrawn@example.com']);
|
|
});
|
|
|
|
it('does not email a favoriter who never opted in', async () => {
|
|
const itemId = await createItem('Withdrawn item');
|
|
const { agent } = await register('quiet@example.com');
|
|
await agent.post(`/api/customers/me/favorites/${itemId}`);
|
|
|
|
await request(app).delete(`/api/admin/items/${itemId}`);
|
|
|
|
expect(removalNotificationsTo()).toEqual([]);
|
|
});
|
|
|
|
it('does not tell them twice when the item had already sold', async () => {
|
|
const itemId = await createItem('Already sold');
|
|
const { agent } = await register('twice-told@example.com');
|
|
await agent.post(`/api/customers/me/favorites/${itemId}`);
|
|
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
|
|
|
await request(app).post(`/api/admin/items/${itemId}/mark-sold`);
|
|
expect(soldNotificationsTo()).toEqual(['twice-told@example.com']);
|
|
|
|
// They already know it has gone; deleting the record should not say so again.
|
|
await request(app).delete(`/api/admin/items/${itemId}`);
|
|
expect(removalNotificationsTo()).toEqual([]);
|
|
});
|
|
|
|
it('does not notify a disabled customer', async () => {
|
|
const itemId = await createItem('Withdrawn item');
|
|
const { agent, id } = await register('disabled-del@example.com');
|
|
await agent.post(`/api/customers/me/favorites/${itemId}`);
|
|
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
|
|
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
|
|
|
|
await request(app).delete(`/api/admin/items/${itemId}`);
|
|
|
|
expect(removalNotificationsTo()).toEqual([]);
|
|
});
|
|
|
|
it('still deletes the item when nobody favorited it', async () => {
|
|
const itemId = await createItem('Unloved');
|
|
expect((await request(app).delete(`/api/admin/items/${itemId}`)).status).toBe(204);
|
|
|
|
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM items WHERE id = $1`, [itemId]);
|
|
expect(rows[0].n).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('filtering the storefront by favorites', () => {
|
|
it('returns only the favorited items', async () => {
|
|
const favorited = await createItem('Oak table');
|
|
await createItem('Elm bench');
|
|
const { agent } = await register('filter@example.com');
|
|
await agent.post(`/api/customers/me/favorites/${favorited}`);
|
|
|
|
const res = await agent.get('/api/items?favorites=1');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
|
|
});
|
|
|
|
it('keeps one customer out of another customer\'s favorites', async () => {
|
|
const mine = await createItem('Oak table');
|
|
const theirs = await createItem('Elm bench');
|
|
const { agent: me } = await register('mine@example.com');
|
|
const { agent: them } = await register('theirs@example.com');
|
|
await me.post(`/api/customers/me/favorites/${mine}`);
|
|
await them.post(`/api/customers/me/favorites/${theirs}`);
|
|
|
|
const res = await me.get('/api/items?favorites=1');
|
|
|
|
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
|
|
});
|
|
|
|
it('still shows a favorite that has sold', async () => {
|
|
const itemId = await createItem('Oak table');
|
|
const { agent } = await register('sold@example.com');
|
|
await agent.post(`/api/customers/me/favorites/${itemId}`);
|
|
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
|
|
|
|
const res = await agent.get('/api/items?favorites=1');
|
|
|
|
// The storefront shows sold items everywhere else, and a favorite that has
|
|
// just sold is often exactly what the customer came back to look at.
|
|
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
|
|
});
|
|
|
|
it('combines with the other filters rather than replacing them', async () => {
|
|
const cheap = await createItem('Oak table');
|
|
const dear = await createItem('Elm bench');
|
|
await pool.query(`UPDATE items SET price_cents = 90000 WHERE id = $1`, [dear]);
|
|
const { agent } = await register('combined@example.com');
|
|
await agent.post(`/api/customers/me/favorites/${cheap}`);
|
|
await agent.post(`/api/customers/me/favorites/${dear}`);
|
|
|
|
const res = await agent.get('/api/items?favorites=1&max_price=50000');
|
|
|
|
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
|
|
});
|
|
|
|
it('answers 401 rather than an empty list when nobody is signed in', async () => {
|
|
await createItem('Oak table');
|
|
|
|
const res = await request(app).get('/api/items?favorites=1');
|
|
|
|
// An empty array would render as "no items match these filters", telling a
|
|
// signed-out visitor they have no favorites instead of that we do not know
|
|
// who they are.
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects a favorites value that is neither on nor off', async () => {
|
|
const { agent } = await register('bogus@example.com');
|
|
|
|
expect((await agent.get('/api/items?favorites=yes')).status).toBe(400);
|
|
});
|
|
|
|
it('leaves the catalogue alone when the flag is off', async () => {
|
|
await createItem('Oak table');
|
|
await createItem('Elm bench');
|
|
const { agent } = await register('off@example.com');
|
|
|
|
const res = await agent.get('/api/items?favorites=0');
|
|
|
|
expect(res.body).toHaveLength(2);
|
|
});
|
|
|
|
it('refuses the favorites filter on the admin inventory', async () => {
|
|
const res = await request(app).get('/api/admin/items?favorites=1');
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|