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'; const sentMail = sendMail as jest.MockedFunction; 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({ 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) VALUES ($1, 1000) 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, 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); } 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); expect(soldNotificationsTo()).toEqual(['watcher@example.com']); }); 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); 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'); await buyer.post(`/api/customers/me/favorites/${itemId}`); await buyer.put('/api/customers/me/favorite-alerts').send({ enabled: true }); await buyViaDemo(buyer, itemId); 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 } = 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); 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); }); });