import { randomUUID } from 'node:crypto'; import { identityApp, type IdentityApp } from './helpers/identity-app'; import { secondActor, seedProduct, variantInput } from './helpers/commerce'; describe('catalogue API', () => { let ctx: IdentityApp; beforeAll(async () => { ctx = await identityApp(); }, 60000); afterAll(async () => { await ctx?.close(); }); beforeEach(async () => { await ctx.clearLimits(); }); const auth = { type: 'bearer' as const }; it('keeps drafts private and publishes only sellable products', async () => { const { product, variant } = await seedProduct(ctx); await ctx .api() .get( `/api/v1/storefront/${ctx.owner.organizationId}/products/${product.id}`, ) .expect(404); await ctx .api() .patch(`/api/v1/products/${product.id}/status`) .auth(ctx.token, auth) .send({ status: 'PUBLISHED' }) .expect(200); const response = await ctx .api() .get( `/api/v1/storefront/${ctx.owner.organizationId}/products/${product.id}`, ) .expect(200); expect(response.body.variants[0].price).toBe('499'); expect(response.body.variants[0].id).toBe(variant.id); expect(response.body).not.toHaveProperty('organizationId'); expect(response.body).not.toHaveProperty('status'); await ctx .api() .get(`/api/v1/storefront/${randomUUID()}/products/${product.id}`) .expect(404); }); it('rejects publishing without a variant and deactivating the final published variant', async () => { const empty = await ctx .api() .post('/api/v1/products') .auth(ctx.token, auth) .send({ name: 'Empty', slug: randomUUID() }) .expect(201); const denied = await ctx .api() .patch(`/api/v1/products/${empty.body.id}/status`) .auth(ctx.token, auth) .send({ status: 'PUBLISHED' }) .expect(409); expect(denied.body.code).toBe('PRODUCT_NOT_PUBLISHABLE'); const { product, variant } = await seedProduct(ctx, true); await ctx .api() .put(`/api/v1/products/${product.id}/variants/${variant.id}`) .auth(ctx.token, auth) .send({ ...variantInput, sku: variant.sku, active: false }) .expect(409); }); it('updates products, variants and groups using explicit organization scope', async () => { const { product, variant } = await seedProduct(ctx); const group = await ctx .api() .post('/api/v1/catalog-groups') .auth(ctx.token, auth) .send({ name: 'Festive', slug: randomUUID(), kind: 'COLLECTION' }) .expect(201); await ctx .api() .put(`/api/v1/products/${product.id}`) .auth(ctx.token, auth) .send({ name: 'Updated', slug: product.slug, groupIds: [group.body.id] }) .expect(200); await ctx .api() .put(`/api/v1/products/${product.id}/variants/${variant.id}`) .auth(ctx.token, auth) .send({ ...variantInput, sku: variant.sku, price: '599.50' }) .expect(200); await ctx .api() .put(`/api/v1/catalog-groups/${group.body.id}`) .auth(ctx.token, auth) .send({ name: 'Diwali', slug: group.body.slug, kind: 'COLLECTION' }) .expect(200); const filtered = await ctx .api() .get(`/api/v1/products?search=Updated&groupId=${group.body.id}`) .auth(ctx.token, auth) .expect(200); expect(filtered.body.map((row: { id: string }) => row.id)).toContain( product.id, ); const detail = await ctx .api() .get(`/api/v1/products/${product.id}`) .auth(ctx.token, auth) .expect(200); expect(detail.body.groups[0].group.name).toBe('Diwali'); await ctx .api() .get('/api/v1/catalog-groups?limit=1') .auth(ctx.token, auth) .expect(200); }); it('denies unprivileged and cross-organization mutations', async () => { const { product } = await seedProduct(ctx); const outsider = await secondActor(ctx, false, [ 'catalog.manage', 'catalog.read', ]); const noRole = await secondActor(ctx); await ctx .api() .get('/api/v1/products') .auth(noRole.token, auth) .expect(403); await ctx .api() .put(`/api/v1/products/${product.id}`) .auth(outsider.token, auth) .send({ name: 'Hijacked', slug: product.slug }) .expect(404); await ctx .api() .get(`/api/v1/products/${product.id}`) .auth(outsider.token, auth) .expect(404); }); it('validates money, text, mass assignment and duplicates', async () => { const { product, variant } = await seedProduct(ctx); await ctx .api() .post(`/api/v1/products/${product.id}/variants`) .auth(ctx.token, auth) .send({ ...variantInput, price: 0.1 }) .expect(400); await ctx .api() .post(`/api/v1/products/${product.id}/variants`) .auth(ctx.token, auth) .send({ ...variantInput, sku: variant.sku }) .expect(409); await ctx .api() .post('/api/v1/products') .auth(ctx.token, auth) .send({ name: '', slug: 'unsafe' }) .expect(400); await ctx .api() .post('/api/v1/products') .auth(ctx.token, auth) .send({ name: 'Unsafe', slug: 'unsafe', organizationId: randomUUID() }) .expect(400); }); it('archives products and omits drafts from public listing', async () => { const { product } = await seedProduct(ctx, true); await ctx .api() .get( `/api/v1/storefront/${ctx.owner.organizationId}/products?search=Rose&limit=5`, ) .expect(200); await ctx .api() .patch(`/api/v1/products/${product.id}/status`) .auth(ctx.token, auth) .send({ status: 'ARCHIVED' }) .expect(200); await ctx .api() .put(`/api/v1/products/${product.id}`) .auth(ctx.token, auth) .send({ name: 'Again', slug: product.slug }) .expect(409); await ctx .api() .get( `/api/v1/storefront/${ctx.owner.organizationId}/products/${product.id}`, ) .expect(404); }); });