Files
tehran_taj/apps/api/src/modules/orders/orders.service.spec.ts
T

418 lines
18 KiB
TypeScript
Raw Normal View History

import { Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { OrdersService } from './orders.service';
/** Order SMS is fire-and-forget (D20); these suites only assert order data. */
const smsStub = { send: jest.fn(async () => ({ ok: true })) };
/** Stands in for Postgres `nextval('order_number_seq')` inside the create tx. */
const nextOrderNumberRaw = () => jest.fn(async () => [{ n: 100n }]);
/**
* Owner ruling 2026-10-08: an order is credited to the customer's expert AT
* THE MOMENT IT IS PLACED (legacy `orders.reagent_id`), and a later change of
* the customer's expert never moves it.
*/
describe('OrdersService.createOrder — sales expert credit', () => {
function build(referredByAgentId: string | null) {
const created: Record<string, unknown>[] = [];
const tx = {
order: {
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
created.push(data);
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
id: 'c1',
referredByAgentId,
goldDebtGrams: new Prisma.Decimal(0),
goldCreditLimitGrams: new Prisma.Decimal(1000),
})),
},
adminUser: { findUnique: jest.fn(async () => null) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const accounting = { orderStatusReached: jest.fn() };
const wage = { quote: jest.fn() };
const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never, smsStub as never);
return { service, created };
}
const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never;
it("records the customer's current expert on the order", async () => {
const { service, created } = build('agent-A');
await service.createOrder(dto, 'c1');
expect(created[0].salesAgentId).toBe('agent-A');
});
it('assigns a short numeric orderNumber from the sequence', async () => {
const { service, created } = build(null);
await service.createOrder(dto, 'c1');
expect(created[0].orderNumber).toBe('100');
});
it('records no expert for a customer without one', async () => {
const { service, created } = build(null);
await service.createOrder(dto, 'c1');
expect(created[0].salesAgentId).toBeNull();
});
});
/** A server quote of 10% for every variant. */
const wageStub = {
quote: jest.fn(async (_c: string, ids: string[]) => new Map(ids.map((id) => [id, { wagePercentSnapshot: '10', discountPercentSnapshot: '0' }]))),
};
/** MON-05 (owner ruling 2026-10-08): «عدم سفارش» stops app orders only. */
describe('OrdersService.createOrder — order ban', () => {
function build(canPlaceOrders: boolean) {
const tx = {
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
id: 'c1',
canPlaceOrders,
referredByAgentId: null,
goldDebtGrams: new Prisma.Decimal(0),
goldCreditLimitGrams: new Prisma.Decimal(1000),
})),
},
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
}
const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never;
it('refuses a suspended customer ordering from the app', async () => {
await expect(build(false).createOrder(dto, 'c1', { placedBy: 'customer' })).rejects.toThrow('غیرفعال');
});
it('lets an operator place an order for a suspended customer', async () => {
await expect(build(false).createOrder(dto, 'c1', { placedBy: 'operator' })).resolves.toMatchObject({ id: 'o1' });
});
it('lets an allowed customer order from the app', async () => {
await expect(build(true).createOrder(dto, 'c1', { placedBy: 'customer' })).resolves.toMatchObject({ id: 'o1' });
});
});
/** MON-08 (owner ruling 2026-10-08): an app order is priced by the server, never by the app. */
describe('OrdersService.createOrder — server-side making charge', () => {
function build(rialWage?: number | string | null) {
const created: Record<string, any>[] = [];
const tx = {
order: {
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
created.push(data);
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = {
getVariantOrThrow: jest.fn(async () => ({
id: 'v1',
rashWeightGrams: '10',
...(rialWage !== undefined ? { rialWage } : {}),
})),
};
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
return { service, created };
}
// A tampered request: zero making charge, a 50% discount and a rial charge.
const tampered = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 0, discountPercent: 50, flatRialCharge: 999 }] } as never;
it('ignores what the app sends and uses the server quote', async () => {
const { service, created } = build();
await service.createOrder(tampered, 'c1', { placedBy: 'customer' });
expect(created[0].totalGoldWeightGrams.toString()).toBe('11');
expect(created[0].flatRialCharge.toString()).toBe('0');
});
it('keeps the numbers an operator entered in the panel', async () => {
const { service, created } = build();
await service.createOrder(tampered, 'c1', { placedBy: 'operator' });
expect(created[0].totalGoldWeightGrams.toString()).toBe('5');
expect(created[0].flatRialCharge.toString()).toBe('999');
});
it('charges the variant rial wage on an app order and ignores a tampered body', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
const { service, created } = build(500000);
await service.createOrder(tampered, 'c1', { placedBy: 'customer' });
expect(created[0].items.create[0].flatRialCharge.toString()).toBe('500000');
expect(created[0].flatRialCharge.toString()).toBe('500000');
const lines = warn.mock.calls.map((c) => String(c[0])).join('\n');
expect(lines).toContain('flatRialCharge sent=999 used=500000');
} finally {
warn.mockRestore();
}
});
it('keeps an operator rial figure when the variant has its own wage', async () => {
const { service, created } = build(500000);
await service.createOrder(tampered, 'c1', { placedBy: 'operator' });
expect(created[0].items.create[0].flatRialCharge.toString()).toBe('999');
expect(created[0].flatRialCharge.toString()).toBe('999');
});
it('copies the variant rial wage when an operator omits the field', async () => {
const omitted = { items: [{ productVariantId: 'v1', quantity: 2, wagePercent: 10 }] } as never;
const { service, created } = build(500000);
await service.createOrder(omitted, 'c1', { placedBy: 'operator' });
expect(created[0].items.create[0].flatRialCharge.toString()).toBe('500000');
expect(created[0].flatRialCharge.toString()).toBe('1000000');
});
/**
* MON-08 step 3 (owner ruling): «تست مقایسه‌ای که نشان دهد نتیجه برای کاربر
* عادی یکسان است». An unmodified app sends what the server derives
* (DECISIONS §"Report — MON-08"), so deriving server-side must not move the
* total for an ordinary customer.
*/
it('gives an honest app exactly the total it sent', async () => {
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10, discountPercent: 0, flatRialCharge: 0 }] } as never;
const a = build();
await a.service.createOrder(honest, 'c1', { placedBy: 'customer' });
const b = build();
await b.service.createOrder(honest, 'c1', { placedBy: 'operator' });
expect(a.created[0].totalGoldWeightGrams.toString()).toBe(b.created[0].totalGoldWeightGrams.toString());
expect(a.created[0].flatRialCharge.toString()).toBe(b.created[0].flatRialCharge.toString());
});
/** MON-08 step 2: a discarded client figure must leave a trace. */
it('logs what the client sent when it differs from the server', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
await build().service.createOrder(tampered, 'c1', { placedBy: 'customer' });
const lines = warn.mock.calls.map((c) => String(c[0])).join('\n');
expect(lines).toContain('wagePercent sent=0 used=10');
expect(lines).toContain('discountPercent sent=50 used=0');
expect(lines).toContain('flatRialCharge sent=999 used=0');
} finally {
warn.mockRestore();
}
});
/**
* «ورود به حساب مشتری»: the order is the customer's in every respect — their
* account, their pricing — and the expert is recorded only in the log, which
* is the audit trail the reference lost by lending out the customer's token.
*/
it('records which expert placed an order on a customer’s behalf', async () => {
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
try {
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never;
const { service, created } = build();
await service.createOrder(honest, 'c1', { placedBy: 'customer', actingAsExpertId: 'adm_9' });
// Still the customer's order, priced by the server.
expect(created[0].customerId).toBe('c1');
expect(created[0].totalGoldWeightGrams.toString()).toBe('11');
const lines = log.mock.calls.map((c) => String(c[0])).join('\n');
expect(lines).toContain('sales expert adm_9 acting as them');
} finally {
log.mockRestore();
}
});
it('logs no expert for an ordinary customer order', async () => {
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
try {
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never;
await build().service.createOrder(honest, 'c1', { placedBy: 'customer' });
const lines = log.mock.calls.map((c) => String(c[0])).join('\n');
expect(lines).not.toContain('acting as');
} finally {
log.mockRestore();
}
});
it('stays quiet when the app sent the right figures', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never;
await build().service.createOrder(honest, 'c1', { placedBy: 'customer' });
const pricing = warn.mock.calls.filter((c) => String(c[0]).includes('Order pricing'));
expect(pricing).toHaveLength(0);
} finally {
warn.mockRestore();
}
});
});
/** App orders are B2B wholesale; RETAIL is only for the admin retail list. */
describe('OrdersService.createOrder — channel', () => {
function build() {
const created: Record<string, any>[] = [];
const tx = {
order: {
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
created.push(data);
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
id: 'c1',
canPlaceOrders: true,
referredByAgentId: null,
goldDebtGrams: new Prisma.Decimal(0),
goldCreditLimitGrams: new Prisma.Decimal(1000),
})),
},
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const service = new OrdersService(
prisma as never,
catalog as never,
{} as never,
{ orderStatusReached: jest.fn() } as never,
wageStub as never,
smsStub as never,
);
return { service, created };
}
const items = [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }];
it('stores an app order as WHOLESALE even when the body omits channel', async () => {
const { service, created } = build();
await service.createOrder({ items } as never, 'c1', { placedBy: 'customer' });
expect(created[0].channel).toBe('WHOLESALE');
});
it('ignores a RETAIL channel sent by the app', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
const { service, created } = build();
await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'customer' });
expect(created[0].channel).toBe('WHOLESALE');
expect(warn.mock.calls.map((c) => String(c[0])).join('\n')).toContain('forcing WHOLESALE');
} finally {
warn.mockRestore();
}
});
it('keeps an operator-supplied RETAIL channel', async () => {
const { service, created } = build();
await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'operator' });
expect(created[0].channel).toBe('RETAIL');
});
it('defaults an operator order without channel to RETAIL', async () => {
const { service, created } = build();
await service.createOrder({ items } as never, 'c1', { placedBy: 'operator' });
expect(created[0].channel).toBe('RETAIL');
});
});
/**
* Legacy ApiMobileController::setOrder:733-766 texted three audiences on every
* placed order. None of it was wired in the target until now.
*/
describe('OrdersService.createOrder — order SMS', () => {
function build(opts: { overLimit?: boolean; agentId?: string | null; agentMobile?: string | null } = {}) {
const sent: { to: string; body: string }[] = [];
const tx = {
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
id: 'c1',
mobile: '09120000001',
firstName: 'علی',
lastName: 'رضایی',
canPlaceOrders: true,
referredByAgentId: opts.agentId ?? null,
goldDebtGrams: new Prisma.Decimal(0),
goldCreditLimitGrams: new Prisma.Decimal(opts.overLimit ? 1 : 1000),
})),
},
adminUser: { findUnique: jest.fn(async () => ({ mobile: opts.agentMobile ?? null })) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const sms = { send: jest.fn(async (to: string, body: string) => { sent.push({ to, body }); return { ok: true }; }) };
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, sms as never);
return { service, sent };
}
const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never;
// SMS is deliberately not awaited (D20), so let the microtasks drain.
const settle = () => new Promise((r) => setImmediate(r));
afterEach(() => { delete process.env.ORDER_ADMIN_MOBILES; });
it('texts the customer that the order was placed', async () => {
const { service, sent } = build();
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('به ثبت رسید. تهران تاج');
expect(toCustomer?.body).not.toContain('معلق');
});
it('tells the customer an over-limit order was suspended', async () => {
const { service, sent } = build({ overLimit: true });
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('عبور از سقف مجاز گرمی در حالت معلق قرار گرفت');
});
it('texts every configured admin with the name and weight', async () => {
process.env.ORDER_ADMIN_MOBILES = '09121111111, 09122222222';
const { service, sent } = build();
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const admins = sent.filter((s) => s.to.startsWith('0912') && s.to !== '09120000001');
expect(admins.map((a) => a.to)).toEqual(['09121111111', '09122222222']);
expect(admins[0].body).toContain('علی رضایی');
expect(admins[0].body).toContain('11');
});
it('texts nobody extra when no admin numbers are configured', async () => {
const { service, sent } = build();
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
expect(sent).toHaveLength(1);
expect(sent[0].to).toBe('09120000001');
});
it("texts the customer's sales expert", async () => {
const { service, sent } = build({ agentId: 'agent-A', agentMobile: '09123333333' });
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toAgent = sent.find((s) => s.to === '09123333333');
expect(toAgent?.body).toContain('سفارشی توسط علی رضایی');
});
it('places the order even when the expert has no mobile', async () => {
const { service, sent } = build({ agentId: 'agent-A', agentMobile: null });
await expect(service.createOrder(dto, 'c1', { placedBy: 'customer' })).resolves.toMatchObject({ id: 'o1' });
await settle();
expect(sent).toHaveLength(1);
});
});