import request from 'supertest'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; beforeEach(async () => { await resetDb(); }); afterAll(async () => { await pool.end(); await closeDb(); }); async function createTag(name: string): Promise { const res = await request(app).post('/api/admin/tags').send({ name }); expect(res.status).toBe(201); return res.body.id; } // Direct insert so a test can put an item in a specific state without going // through the transitions being tested. async function insertItem( name: string, priceCents: number, status: string, tagIds: number[] = [] ): Promise { const { rows } = await pool.query( `INSERT INTO items (name, price_cents, status) VALUES ($1, $2, $3) RETURNING id`, [name, priceCents, status] ); const id = rows[0].id; for (const tagId of tagIds) { await pool.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [id, tagId]); } return id; } describe('a new item is staged rather than published', () => { it('arrives pending when created through the admin API', async () => { const res = await request(app) .post('/api/admin/items') .field('name', 'Fresh') .field('description', '') .field('price', '25'); expect(res.status).toBe(200); expect(res.body.status).toBe('pending'); }); }); // Each of these is a separate query, so fixing one proves nothing about the // others. They are tested separately for that reason. describe('a pending item does not reach the storefront', () => { it('is absent from the catalogue listing', async () => { await insertItem('Staged', 1000, 'pending'); await insertItem('Live', 2000, 'available'); const res = await request(app).get('/api/items'); expect(res.status).toBe(200); expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Live']); }); // Hiding it from the list but serving it by id would leave it reachable to // anyone who guessed the id or kept an old link. it('is not fetchable by direct id', async () => { const id = await insertItem('Staged', 1000, 'pending'); const res = await request(app).get(`/api/items/${id}`); expect(res.status).toBe(404); }); it('is still fetchable by id once published', async () => { const id = await insertItem('Staged', 1000, 'pending'); await request(app).post(`/api/admin/items/${id}/mark-available`); const res = await request(app).get(`/api/items/${id}`); expect(res.status).toBe(200); expect(res.body.name).toBe('Staged'); }); // A count including pending items would show a customer "Rare (1)", and // filtering by it would then report that nothing matches. it('is not counted in the filter drawer tag counts', async () => { const tag = await createTag('rare'); await insertItem('Staged', 1000, 'pending', [tag]); await insertItem('Live', 2000, 'available', [tag]); const res = await request(app).get('/api/filters'); const rare = res.body.tags.find((t: { name: string }) => t.name === 'rare'); expect(rare.item_count).toBe(1); }); // The tag must still be listed, at zero. Excluding pending items with a WHERE // rather than in the count would drop the tag's row entirely and make the tag // vanish from the drawer. it('leaves a tag whose only item is pending listed with a count of zero', async () => { const tag = await createTag('unreleased'); await insertItem('Staged', 1000, 'pending', [tag]); const res = await request(app).get('/api/filters'); const unreleased = res.body.tags.find((t: { name: string }) => t.name === 'unreleased'); expect(unreleased).toBeDefined(); expect(unreleased.item_count).toBe(0); }); // A staged item priced far outside the live range would stretch the slider to // a range no visible item occupies. it('does not stretch the price slider bounds', async () => { await insertItem('Cheap live', 1000, 'available'); await insertItem('Dear live', 5000, 'available'); await insertItem('Absurd staged', 999999, 'pending'); const res = await request(app).get('/api/filters'); expect(res.body.priceRange.min_cents).toBe(1000); expect(res.body.priceRange.max_cents).toBe(5000); }); }); describe('asking the public API for pending items', () => { // Refused rather than answered with an empty list, which would read as "no // items match" instead of "you may not ask that". it('is refused on the storefront route', async () => { await insertItem('Staged', 1000, 'pending'); const res = await request(app).get('/api/items?status=pending'); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid status'); }); it('is still allowed on the admin route, which is the point of it', async () => { await insertItem('Staged', 1000, 'pending'); await insertItem('Live', 2000, 'available'); const res = await request(app).get('/api/admin/items?status=pending'); expect(res.status).toBe(200); expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Staged']); }); it('leaves the other statuses working on the storefront', async () => { await insertItem('Live', 1000, 'available'); await insertItem('Gone', 2000, 'sold'); const res = await request(app).get('/api/items?status=sold'); expect(res.status).toBe(200); expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Gone']); }); }); describe('unpublishing', () => { it('returns an available item to pending and removes it from the catalogue', async () => { const id = await insertItem('Live', 1000, 'available'); const res = await request(app).post(`/api/admin/items/${id}/unpublish`); expect(res.status).toBe(200); expect(res.body.status).toBe('pending'); const listing = await request(app).get('/api/items'); expect(listing.body).toHaveLength(0); }); // Not a draft: someone is holding it in their cart right now, and hiding it // would strand them mid-checkout. it('refuses a reserved item and says why', async () => { const id = await insertItem('Held', 1000, 'reserved'); const res = await request(app).post(`/api/admin/items/${id}/unpublish`); expect(res.status).toBe(400); expect(res.body.error).toContain('holding this item'); const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [id]); expect(rows[0].status).toBe('reserved'); }); // Not a draft either: a sold item is a record of something that happened. it('refuses a sold item and says why', async () => { const id = await insertItem('Gone', 1000, 'sold'); const res = await request(app).post(`/api/admin/items/${id}/unpublish`); expect(res.status).toBe(400); expect(res.body.error).toContain('sold item'); const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [id]); expect(rows[0].status).toBe('sold'); }); it('refuses an item that is already pending', async () => { const id = await insertItem('Staged', 1000, 'pending'); const res = await request(app).post(`/api/admin/items/${id}/unpublish`); expect(res.status).toBe(400); expect(res.body.error).toContain('already pending'); }); it('reports a missing item as not found rather than as a bad request', async () => { const res = await request(app).post('/api/admin/items/999999/unpublish'); expect(res.status).toBe(404); }); }); describe('publishing', () => { it('puts a pending item into the catalogue', async () => { const id = await insertItem('Staged', 1000, 'pending'); expect((await request(app).get('/api/items')).body).toHaveLength(0); const res = await request(app).post(`/api/admin/items/${id}/mark-available`); expect(res.status).toBe(200); expect(res.body.status).toBe('available'); const listing = await request(app).get('/api/items'); expect(listing.body.map((i: { name: string }) => i.name)).toEqual(['Staged']); }); });