diff --git a/src/catalog/catalog-query.ts b/src/catalog/catalog-query.ts new file mode 100644 index 0000000..ec4fb5f --- /dev/null +++ b/src/catalog/catalog-query.ts @@ -0,0 +1,17 @@ +import type { Prisma } from '../generated/prisma/client'; +import type { CatalogQuery } from './catalog.schemas'; + +export function catalogWhere( + organizationId: string, + query: CatalogQuery, + publishedOnly = false, +): Prisma.ProductWhereInput { + return { + organizationId, + ...(publishedOnly ? { status: 'PUBLISHED' } : {}), + ...(query.search + ? { name: { contains: query.search, mode: 'insensitive' } } + : {}), + ...(query.groupId ? { groups: { some: { groupId: query.groupId } } } : {}), + }; +} diff --git a/src/catalog/catalog.helpers.ts b/src/catalog/catalog.helpers.ts new file mode 100644 index 0000000..37a0c31 --- /dev/null +++ b/src/catalog/catalog.helpers.ts @@ -0,0 +1,31 @@ +import type { Prisma } from '../generated/prisma/client'; +import { AppError } from '../common/errors/app-error'; + +export async function scopedProduct( + tx: Prisma.TransactionClient, + organizationId: string, + id: string, +) { + const product = await tx.product.findFirst({ where: { id, organizationId } }); + if (!product) throw new AppError('PRODUCT_NOT_FOUND'); + return product; +} +export async function editableProduct( + tx: Prisma.TransactionClient, + organizationId: string, + id: string, +) { + const product = await scopedProduct(tx, organizationId, id); + if (product.status === 'ARCHIVED') throw new AppError('PRODUCT_ARCHIVED'); + return product; +} +export async function validateGroups( + tx: Prisma.TransactionClient, + organizationId: string, + groupIds: string[], +) { + const count = await tx.catalogGroup.count({ + where: { organizationId, id: { in: groupIds } }, + }); + if (count !== groupIds.length) throw new AppError('GROUP_NOT_FOUND'); +} diff --git a/src/catalog/catalog.module.ts b/src/catalog/catalog.module.ts new file mode 100644 index 0000000..132a280 --- /dev/null +++ b/src/catalog/catalog.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { IdentityModule } from '../identity/identity.module'; +import { ProductStore } from './product.store'; +import { VariantStore } from './variant.store'; +import { GroupStore } from './group.store'; +import { StorefrontStore } from './storefront.store'; +import { ProductsController } from './products.controller'; +import { GroupsController } from './groups.controller'; +import { StorefrontController } from './storefront.controller'; +import { StorefrontGuard } from './storefront.guard'; + +@Module({ + imports: [DatabaseModule, IdentityModule], + providers: [ + ProductStore, + VariantStore, + GroupStore, + StorefrontStore, + StorefrontGuard, + ], + controllers: [ProductsController, GroupsController, StorefrontController], +}) +export class CatalogModule {} diff --git a/src/catalog/catalog.schemas.ts b/src/catalog/catalog.schemas.ts new file mode 100644 index 0000000..6b2108f --- /dev/null +++ b/src/catalog/catalog.schemas.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import { text, ids } from '../common/input'; +import { pageSchema } from '../identity/identity.schemas'; + +const slug = (max: number) => + z + .string() + .max(max) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); +export const productSchema = z + .object({ + name: text(160), + slug: slug(160), + description: text(5000, 0).default(''), + groupIds: ids.default([]), + }) + .strict(); +export const productStatusSchema = z + .object({ status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']) }) + .strict(); +export const groupSchema = z + .object({ + name: text(100), + slug: slug(100), + kind: z.enum(['CATEGORY', 'COLLECTION']), + }) + .strict(); +export const variantSchema = z + .object({ + sku: z + .string() + .trim() + .toUpperCase() + .max(64) + .regex(/^[A-Z0-9][A-Z0-9_-]*$/), + name: text(160), + price: z + .string() + .regex(/^(0|[1-9]\d{0,9})\.\d{2}$/) + .refine((value) => value !== '0.00'), + currency: z.enum(['INR', 'USD', 'EUR', 'GBP']).default('INR'), + active: z.boolean().default(true), + attributes: z + .record( + z + .string() + .regex(/^[a-z][a-z0-9_]{0,31}$/) + .refine((key) => !['constructor', 'prototype'].includes(key)), + text(100), + ) + .refine((value) => Object.keys(value).length <= 20) + .default({}), + }) + .strict(); +export const catalogQuery = pageSchema + .extend({ + search: text(100).optional(), + groupId: z.uuid().optional(), + }) + .strict(); +export type ProductInput = z.infer; +export type VariantInput = z.infer; +export type GroupInput = z.infer; +export type CatalogQuery = z.infer; diff --git a/src/catalog/group.store.ts b/src/catalog/group.store.ts new file mode 100644 index 0000000..38fe17e --- /dev/null +++ b/src/catalog/group.store.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { AccessStore } from '../identity/access.store'; +import { recordAudit } from '../identity/audit'; +import type { Principal } from '../identity/identity.types'; +import type { PageInput } from '../identity/identity.schemas'; +import { AppError } from '../common/errors/app-error'; +import type { GroupInput } from './catalog.schemas'; + +@Injectable() +export class GroupStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + list(actor: Principal, page: PageInput) { + return this.db.catalogGroup.findMany({ + where: { organizationId: actor.organizationId }, + orderBy: { id: 'asc' }, + take: page.limit, + skip: page.offset, + }); + } + save(actor: Principal, input: GroupInput, id?: string) { + return this.access.mutate(actor, 'catalog.manage', async (tx) => { + if ( + id && + !(await tx.catalogGroup.findFirst({ + where: { id, organizationId: actor.organizationId }, + })) + ) { + throw new AppError('GROUP_NOT_FOUND'); + } + const group = id + ? await tx.catalogGroup.update({ where: { id }, data: input }) + : await tx.catalogGroup.create({ + data: { ...input, organizationId: actor.organizationId }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + id ? 'catalog_group.updated' : 'catalog_group.created', + group.id, + ); + return group; + }); + } +} diff --git a/src/catalog/groups.controller.ts b/src/catalog/groups.controller.ts new file mode 100644 index 0000000..316955a --- /dev/null +++ b/src/catalog/groups.controller.ts @@ -0,0 +1,49 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Put, + Query, +} from '@nestjs/common'; +import { SchemaPipe } from '../common/validation.pipe'; +import { + CurrentPrincipal, + RequirePermission, +} from '../identity/access.decorator'; +import { pageSchema, type PageInput } from '../identity/identity.schemas'; +import type { Principal } from '../identity/identity.types'; +import { GroupStore } from './group.store'; +import { groupSchema, type GroupInput } from './catalog.schemas'; + +@Controller('catalog-groups') +export class GroupsController { + constructor(private readonly groups: GroupStore) {} + @Get() + @RequirePermission('catalog.read') + list( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(pageSchema)) page: PageInput, + ) { + return this.groups.list(actor, page); + } + @Post() + @RequirePermission('catalog.manage') + create( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(groupSchema)) input: GroupInput, + ) { + return this.groups.save(actor, input); + } + @Put(':id') + @RequirePermission('catalog.manage') + update( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Body(new SchemaPipe(groupSchema)) input: GroupInput, + ) { + return this.groups.save(actor, input, id); + } +} diff --git a/src/catalog/product.store.ts b/src/catalog/product.store.ts new file mode 100644 index 0000000..197066f --- /dev/null +++ b/src/catalog/product.store.ts @@ -0,0 +1,97 @@ +import { catalogWhere } from './catalog-query'; +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { AccessStore } from '../identity/access.store'; +import { recordAudit } from '../identity/audit'; +import type { Principal } from '../identity/identity.types'; +import { AppError } from '../common/errors/app-error'; +import { + editableProduct, + scopedProduct, + validateGroups, +} from './catalog.helpers'; +import type { CatalogQuery, ProductInput } from './catalog.schemas'; + +@Injectable() +export class ProductStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + list(actor: Principal, query: CatalogQuery) { + return this.db.product.findMany({ + where: catalogWhere(actor.organizationId, query), + select: { id: true, name: true, slug: true, status: true }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: query.limit, + skip: query.offset, + }); + } + async get(actor: Principal, id: string) { + await scopedProduct(this.db, actor.organizationId, id); + return this.db.product.findUnique({ + where: { id }, + include: { + variants: { orderBy: { id: 'asc' } }, + groups: { include: { group: true } }, + }, + }); + } + save(actor: Principal, input: ProductInput, id?: string) { + return this.access.mutate(actor, 'catalog.manage', async (tx) => { + if (id) await editableProduct(tx, actor.organizationId, id); + await validateGroups(tx, actor.organizationId, input.groupIds); + const { groupIds, ...data } = input; + const product = id + ? await tx.product.update({ where: { id }, data }) + : await tx.product.create({ + data: { ...data, organizationId: actor.organizationId }, + }); + await tx.productGroup.deleteMany({ where: { productId: product.id } }); + await tx.productGroup.createMany({ + data: groupIds.map((groupId) => ({ + productId: product.id, + groupId, + organizationId: actor.organizationId, + })), + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + id ? 'product.updated' : 'product.created', + product.id, + ); + return product; + }); + } + setStatus( + actor: Principal, + id: string, + status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED', + ) { + return this.access.mutate(actor, 'catalog.publish', async (tx) => { + await editableProduct(tx, actor.organizationId, id); + if ( + status === 'PUBLISHED' && + !(await tx.productVariant.count({ + where: { productId: id, active: true }, + })) + ) { + throw new AppError('PRODUCT_NOT_PUBLISHABLE'); + } + const product = await tx.product.update({ + where: { id }, + data: { status }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + `product.${status.toLowerCase()}`, + id, + ); + return product; + }); + } +} diff --git a/src/catalog/products.controller.ts b/src/catalog/products.controller.ts new file mode 100644 index 0000000..35a6b18 --- /dev/null +++ b/src/catalog/products.controller.ts @@ -0,0 +1,98 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { SchemaPipe } from '../common/validation.pipe'; +import { + CurrentPrincipal, + RequirePermission, +} from '../identity/access.decorator'; +import type { Principal } from '../identity/identity.types'; +import { ProductStore } from './product.store'; +import { VariantStore } from './variant.store'; +import { + catalogQuery, + productSchema, + productStatusSchema, + variantSchema, + type CatalogQuery, + type ProductInput, + type VariantInput, +} from './catalog.schemas'; + +@Controller('products') +export class ProductsController { + constructor( + private readonly products: ProductStore, + private readonly variants: VariantStore, + ) {} + @Get() + @RequirePermission('catalog.read') + list( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(catalogQuery)) query: CatalogQuery, + ) { + return this.products.list(actor, query); + } + @Get(':id') + @RequirePermission('catalog.read') + get( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.products.get(actor, id); + } + @Post() + @RequirePermission('catalog.manage') + create( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(productSchema)) input: ProductInput, + ) { + return this.products.save(actor, input); + } + @Put(':id') + @RequirePermission('catalog.manage') + update( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Body(new SchemaPipe(productSchema)) input: ProductInput, + ) { + return this.products.save(actor, input, id); + } + @Patch(':id/status') + @RequirePermission('catalog.publish') + status( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Body(new SchemaPipe(productStatusSchema)) + input: { status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' }, + ) { + return this.products.setStatus(actor, id, input.status); + } + @Post(':id/variants') + @RequirePermission('catalog.manage') + createVariant( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Body(new SchemaPipe(variantSchema)) input: VariantInput, + ) { + return this.variants.save(actor, id, input); + } + @Put(':id/variants/:variantId') + @RequirePermission('catalog.manage') + updateVariant( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Body(new SchemaPipe(variantSchema)) input: VariantInput, + ) { + return this.variants.save(actor, id, input, variantId); + } +} diff --git a/src/catalog/storefront.controller.ts b/src/catalog/storefront.controller.ts new file mode 100644 index 0000000..7353480 --- /dev/null +++ b/src/catalog/storefront.controller.ts @@ -0,0 +1,34 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + UseGuards, +} from '@nestjs/common'; +import { SchemaPipe } from '../common/validation.pipe'; +import { Public } from '../identity/access.decorator'; +import { StorefrontStore } from './storefront.store'; +import { StorefrontGuard } from './storefront.guard'; +import { catalogQuery, type CatalogQuery } from './catalog.schemas'; + +@Public() +@UseGuards(StorefrontGuard) +@Controller('storefront/:organizationId/products') +export class StorefrontController { + constructor(private readonly storefront: StorefrontStore) {} + @Get() + list( + @Param('organizationId', ParseUUIDPipe) organizationId: string, + @Query(new SchemaPipe(catalogQuery)) query: CatalogQuery, + ) { + return this.storefront.list(organizationId, query); + } + @Get(':id') + get( + @Param('organizationId', ParseUUIDPipe) organizationId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.storefront.get(organizationId, id); + } +} diff --git a/src/catalog/storefront.guard.ts b/src/catalog/storefront.guard.ts new file mode 100644 index 0000000..b355af7 --- /dev/null +++ b/src/catalog/storefront.guard.ts @@ -0,0 +1,13 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import type { Request } from 'express'; +import { RateLimitService } from '../identity/rate-limit.service'; + +@Injectable() +export class StorefrontGuard implements CanActivate { + constructor(private readonly limits: RateLimitService) {} + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + await this.limits.consume(`storefront:${request.ip}`, 120, 60); + return true; + } +} diff --git a/src/catalog/storefront.store.ts b/src/catalog/storefront.store.ts new file mode 100644 index 0000000..68e0758 --- /dev/null +++ b/src/catalog/storefront.store.ts @@ -0,0 +1,50 @@ +import { catalogWhere } from './catalog-query'; +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { AppError } from '../common/errors/app-error'; +import type { CatalogQuery } from './catalog.schemas'; + +const storefrontView = { + id: true, + name: true, + slug: true, + description: true, + variants: { + where: { active: true }, + orderBy: { id: 'asc' as const }, + select: { + id: true, + sku: true, + name: true, + price: true, + currency: true, + attributes: true, + }, + }, + groups: { + select: { + group: { select: { id: true, name: true, slug: true, kind: true } }, + }, + }, +} as const; +@Injectable() +export class StorefrontStore { + constructor(private readonly db: DatabaseService) {} + list(organizationId: string, query: CatalogQuery) { + return this.db.product.findMany({ + where: catalogWhere(organizationId, query, true), + select: { id: true, name: true, slug: true }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: query.limit, + skip: query.offset, + }); + } + async get(organizationId: string, id: string) { + const product = await this.db.product.findFirst({ + where: { id, organizationId, status: 'PUBLISHED' }, + select: storefrontView, + }); + if (!product) throw new AppError('PRODUCT_NOT_FOUND'); + return product; + } +} diff --git a/src/catalog/variant.store.ts b/src/catalog/variant.store.ts new file mode 100644 index 0000000..562d1e2 --- /dev/null +++ b/src/catalog/variant.store.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { AccessStore } from '../identity/access.store'; +import { recordAudit } from '../identity/audit'; +import type { Principal } from '../identity/identity.types'; +import { AppError } from '../common/errors/app-error'; +import { editableProduct } from './catalog.helpers'; +import type { VariantInput } from './catalog.schemas'; + +@Injectable() +export class VariantStore { + constructor(private readonly access: AccessStore) {} + save(actor: Principal, productId: string, input: VariantInput, id?: string) { + return this.access.mutate(actor, 'catalog.manage', async (tx) => { + const product = await editableProduct( + tx, + actor.organizationId, + productId, + ); + if ( + id && + !(await tx.productVariant.findFirst({ + where: { id, productId, organizationId: actor.organizationId }, + })) + ) { + throw new AppError('VARIANT_NOT_FOUND'); + } + if ( + !id && + (await tx.productVariant.count({ where: { productId } })) >= 100 + ) + throw new AppError('VARIANT_LIMIT'); + if ( + id && + !input.active && + product.status === 'PUBLISHED' && + !(await tx.productVariant.count({ + where: { productId, active: true, id: { not: id } }, + })) + ) { + throw new AppError('VARIANT_REQUIRED'); + } + const variant = id + ? await tx.productVariant.update({ where: { id }, data: input }) + : await tx.productVariant.create({ + data: { ...input, productId, organizationId: actor.organizationId }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + id ? 'variant.updated' : 'variant.created', + variant.id, + ); + return variant; + }); + } +} diff --git a/test/catalog.spec.ts b/test/catalog.spec.ts new file mode 100644 index 0000000..115c69a --- /dev/null +++ b/test/catalog.spec.ts @@ -0,0 +1,192 @@ +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); + }); +}); diff --git a/test/helpers/commerce.ts b/test/helpers/commerce.ts new file mode 100644 index 0000000..7d76ff1 --- /dev/null +++ b/test/helpers/commerce.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import type { IdentityApp } from './identity-app'; +import { issueToken } from '../../src/identity/tokens'; + +export const variantInput = { + sku: 'CANDLE', + name: 'Rose 200g', + price: '499.00', + currency: 'INR' as const, + active: true, + attributes: { fragrance: 'rose' }, +}; +export const addressInput = { + recipient: 'Test Customer', + line1: '12 Test Road', + line2: '', + city: 'Pune', + region: 'Maharashtra', + postalCode: '411001', + countryCode: 'IN', + phone: '+919876543210', + isDefault: false, +}; +export async function seedProduct(ctx: IdentityApp, publish = false) { + const slug = 'candle-' + randomUUID(); + const product = await ctx + .api() + .post('/api/v1/products') + .auth(ctx.token, { type: 'bearer' }) + .send({ name: 'Rose Candle', slug, description: 'Handmade candle' }) + .expect(201); + const variant = await ctx + .api() + .post(`/api/v1/products/${product.body.id}/variants`) + .auth(ctx.token, { type: 'bearer' }) + .send({ ...variantInput, sku: randomUUID() }) + .expect(201); + if (publish) + await ctx + .api() + .patch(`/api/v1/products/${product.body.id}/status`) + .auth(ctx.token, { type: 'bearer' }) + .send({ status: 'PUBLISHED' }) + .expect(200); + return { product: product.body, variant: variant.body }; +} +export async function seedStock(ctx: IdentityApp, onHand = 10) { + const catalog = await seedProduct(ctx, true); + const warehouse = await ctx + .api() + .post('/api/v1/inventory/warehouses') + .auth(ctx.token, { type: 'bearer' }) + .send({ name: randomUUID() }) + .expect(201); + const stock = await ctx + .api() + .post('/api/v1/inventory/stock-items') + .auth(ctx.token, { type: 'bearer' }) + .send({ warehouseId: warehouse.body.id, variantId: catalog.variant.id }) + .expect(201); + if (onHand) + await ctx + .api() + .post('/api/v1/inventory/adjustments') + .auth(ctx.token, { type: 'bearer' }) + .send({ + stockItemId: stock.body.id, + delta: onHand, + reason: 'Opening balance', + idempotencyKey: randomUUID(), + }) + .expect(201); + return { ...catalog, warehouse: warehouse.body, stock: stock.body }; +} +export async function secondActor( + ctx: IdentityApp, + sameOrganization = true, + permissions: string[] = [], +) { + const organizationId = sameOrganization + ? ctx.owner.organizationId + : ( + await ctx.db.organization.create({ + data: { name: 'Other organization' }, + }) + ).id; + const user = await ctx.db.user.create({ + data: { + organizationId, + email: randomUUID() + '@example.com', + name: 'Test account', + passwordHash: 'not-a-login-credential', + status: 'ACTIVE', + }, + }); + if (permissions.length) { + const role = await ctx.db.role.create({ + data: { organizationId, name: randomUUID(), permissions }, + }); + await ctx.db.userRole.create({ + data: { organizationId, userId: user.id, roleId: role.id }, + }); + } + const token = issueToken(); + await ctx.db.session.create({ + data: { + userId: user.id, + tokenHash: token.tokenHash, + expiresAt: new Date(Date.now() + 60000), + }, + }); + return { token: token.token, userId: user.id, organizationId }; +}