Files
redefined-designs/backend/tests/integration/favorites.integration.test.ts
T
bermudalambandClaude Opus 5 f626f27e75
SonarQube Analysis / sonarqube (pull_request) Successful in 2m46s
Tests / backend-unit (pull_request) Successful in 44s
Tests / frontend-e2e (pull_request) Failing after 9m48s
feat: favorite items and notify when a favorite is sold (#34)
Customers can favorite and unfavorite items from the storefront, opt in to being told when a favorite is sold to someone else, and manage that preference from their account page.

The opt-in is a consent of its own rather than the existing marketing flag. Being told that a specific item you asked about has gone is a narrower thing than agreeing to marketing, and folding one into the other would leave marketing_consent_text no longer describing what was actually agreed to. It is recorded the same way as the marketing consent — flag, timestamp, and the exact wording shown — and accepting it does not set marketing_consent.

The prompt appears only after a customer has actually favorited something, so the reason for asking is concrete rather than an abstract marketing ask, and it says plainly that it is separate from marketing email. Declining keeps the favorite.

Notifications fire when an item reaches sold, either through checkout or an admin marking it sold, and never to the buyer — telling someone the item they just bought is unavailable reads as a bug. Reserved is deliberately not a trigger: reservations expire and get released, so a "gone" email would often be about an item still for sale. Disabled accounts are excluded, per #33.

completeCheckout now returns the sold item ids and the buyer so its three call sites can notify after COMMIT. Sending inside the transaction would email people about a sale that then rolled back, and would hold the transaction open for SMTP. Each message is sent independently so one bad address cannot stop the rest, and the sale has already succeeded regardless.

Favoriting while signed out opens the existing inline register/login modal, exactly as Add to Cart does, and completes the favorite on success.

Also fixes a latent bug in the same component: while the session was still resolving, `customer` is null for a signed-in visitor too, so clicking Add to Cart or the new heart in that window prompted them to sign in again. Both now ignore clicks until the session has resolved, and the control shows as loading meanwhile.

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

243 lines
9.3 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]));
}
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([]);
});
});