Files
redefined-designs/backend/tests/integration/favorites.integration.test.ts
T
bermudalambandClaude Opus 5 009c73e359
SonarQube Analysis / sonarqube (pull_request) Successful in 2m35s
Tests / backend-unit (pull_request) Successful in 35s
Tests / frontend-e2e (pull_request) Failing after 8m35s
feat: tell favoriters when an item is withdrawn (#34)
Deleting an item cascades its favorites away, so anyone watching it lost the record silently and heard nothing. Recipients are now collected before the delete, since after it there is nobody left to look up, and the mail is sent only once the delete has succeeded so nobody hears about a withdrawal that did not happen.

Items that had already sold are excluded. Their favoriters were told at the point of sale, and a second "no longer available" for the same item reads as a duplicate rather than news.

The wording differs from the sale notification — withdrawn rather than sold — because the customer did not lose out to another buyer and saying so would be untrue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:29:00 -05:00

307 lines
12 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';
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({ 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<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);
}
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);
});
});