193 lines
6.0 KiB
TypeScript
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);
|
|
});
|
|
});
|