manicanldes-backend/test/catalog.spec.ts

193 lines
6.0 KiB
TypeScript

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: '<script>alert(1)</script>', 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);
});
});