Files
tehran_tala/apps/api/src/modules/orders/orders.service.spec.ts
T
s.esmaeiliandClaude Haiku 5.5 532ceee189 feat: move brand name to one config and redesign the accounting card
اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-14 13:31:20 +03:30

290 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })) };
/**
* 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 };
}),
},
};
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('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 })) } };
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() {
const created: Record<string, any>[] = [];
const tx = { order: { create: jest.fn(async ({ data }: { data: Record<string, any> }) => { created.push(data); return { id: 'o1', ...data }; }) } };
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 };
}
// 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');
});
/**
* 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();
}
});
});
/**
* 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 })) } };
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);
});
});