[FASE 4] feat: Add SaaS billing entities and fix unit tests
- Add PlanLimit entity for plan limits tracking - Add Coupon entity for discount coupons - Add CouponRedemption entity for coupon usage - Add StripeEvent entity for webhook processing - Fix purchases.service tests (mock sequencesService) - Fix orders.service tests (mock stockReservation) - Add partners.controller tests (13 new tests) Test Results: 361 tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
edadaf3180
commit
0bdb2eed65
@ -0,0 +1,44 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { Coupon } from './coupon.entity.js';
|
||||
import { TenantSubscription } from './tenant-subscription.entity.js';
|
||||
|
||||
@Entity({ name: 'coupon_redemptions', schema: 'billing' })
|
||||
@Unique(['couponId', 'tenantId'])
|
||||
export class CouponRedemption {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'coupon_id', type: 'uuid' })
|
||||
couponId!: string;
|
||||
|
||||
@ManyToOne(() => Coupon, (coupon) => coupon.redemptions)
|
||||
@JoinColumn({ name: 'coupon_id' })
|
||||
coupon!: Coupon;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId!: string;
|
||||
|
||||
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
|
||||
subscriptionId?: string;
|
||||
|
||||
@ManyToOne(() => TenantSubscription, { nullable: true })
|
||||
@JoinColumn({ name: 'subscription_id' })
|
||||
subscription?: TenantSubscription;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2 })
|
||||
discountAmount!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'redeemed_at', type: 'timestamptz' })
|
||||
redeemedAt!: Date;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||
expiresAt?: Date;
|
||||
}
|
||||
72
src/modules/billing-usage/entities/coupon.entity.ts
Normal file
72
src/modules/billing-usage/entities/coupon.entity.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { CouponRedemption } from './coupon-redemption.entity.js';
|
||||
|
||||
export type DiscountType = 'percentage' | 'fixed';
|
||||
export type DurationPeriod = 'once' | 'forever' | 'months';
|
||||
|
||||
@Entity({ name: 'coupons', schema: 'billing' })
|
||||
export class Coupon {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ name: 'discount_type', type: 'varchar', length: 20 })
|
||||
discountType!: DiscountType;
|
||||
|
||||
@Column({ name: 'discount_value', type: 'decimal', precision: 10, scale: 2 })
|
||||
discountValue!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 3, default: 'MXN' })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: 'applicable_plans', type: 'uuid', array: true, default: [] })
|
||||
applicablePlans!: string[];
|
||||
|
||||
@Column({ name: 'min_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
minAmount!: number;
|
||||
|
||||
@Column({ name: 'duration_period', type: 'varchar', length: 20, default: 'once' })
|
||||
durationPeriod!: DurationPeriod;
|
||||
|
||||
@Column({ name: 'duration_months', type: 'integer', nullable: true })
|
||||
durationMonths?: number;
|
||||
|
||||
@Column({ name: 'max_redemptions', type: 'integer', nullable: true })
|
||||
maxRedemptions?: number;
|
||||
|
||||
@Column({ name: 'current_redemptions', type: 'integer', default: 0 })
|
||||
currentRedemptions!: number;
|
||||
|
||||
@Column({ name: 'valid_from', type: 'timestamptz', nullable: true })
|
||||
validFrom?: Date;
|
||||
|
||||
@Column({ name: 'valid_until', type: 'timestamptz', nullable: true })
|
||||
validUntil?: Date;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@OneToMany(() => CouponRedemption, (redemption) => redemption.coupon)
|
||||
redemptions!: CouponRedemption[];
|
||||
}
|
||||
@ -1,9 +1,13 @@
|
||||
export { SubscriptionPlan, PlanType } from './subscription-plan.entity';
|
||||
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity';
|
||||
export { UsageTracking } from './usage-tracking.entity';
|
||||
export { UsageEvent, EventCategory } from './usage-event.entity';
|
||||
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity';
|
||||
export { InvoiceItemType } from './invoice-item.entity';
|
||||
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity';
|
||||
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity';
|
||||
export { PlanFeature } from './plan-feature.entity';
|
||||
export { SubscriptionPlan, PlanType } from './subscription-plan.entity.js';
|
||||
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity.js';
|
||||
export { UsageTracking } from './usage-tracking.entity.js';
|
||||
export { UsageEvent, EventCategory } from './usage-event.entity.js';
|
||||
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity.js';
|
||||
export { InvoiceItemType } from './invoice-item.entity.js';
|
||||
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity.js';
|
||||
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity.js';
|
||||
export { PlanFeature } from './plan-feature.entity.js';
|
||||
export { PlanLimit, LimitType } from './plan-limit.entity.js';
|
||||
export { Coupon, DiscountType, DurationPeriod } from './coupon.entity.js';
|
||||
export { CouponRedemption } from './coupon-redemption.entity.js';
|
||||
export { StripeEvent } from './stripe-event.entity.js';
|
||||
|
||||
52
src/modules/billing-usage/entities/plan-limit.entity.ts
Normal file
52
src/modules/billing-usage/entities/plan-limit.entity.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { SubscriptionPlan } from './subscription-plan.entity.js';
|
||||
|
||||
export type LimitType = 'monthly' | 'daily' | 'total' | 'per_user';
|
||||
|
||||
@Entity({ name: 'plan_limits', schema: 'billing' })
|
||||
export class PlanLimit {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'plan_id', type: 'uuid' })
|
||||
planId!: string;
|
||||
|
||||
@ManyToOne(() => SubscriptionPlan, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'plan_id' })
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Column({ name: 'limit_key', type: 'varchar', length: 100 })
|
||||
limitKey!: string;
|
||||
|
||||
@Column({ name: 'limit_name', type: 'varchar', length: 255 })
|
||||
limitName!: string;
|
||||
|
||||
@Column({ name: 'limit_value', type: 'integer' })
|
||||
limitValue!: number;
|
||||
|
||||
@Column({ name: 'limit_type', type: 'varchar', length: 50, default: 'monthly' })
|
||||
limitType!: LimitType;
|
||||
|
||||
@Column({ name: 'allow_overage', type: 'boolean', default: false })
|
||||
allowOverage!: boolean;
|
||||
|
||||
@Column({ name: 'overage_unit_price', type: 'decimal', precision: 10, scale: 4, default: 0 })
|
||||
overageUnitPrice!: number;
|
||||
|
||||
@Column({ name: 'overage_currency', type: 'varchar', length: 3, default: 'MXN' })
|
||||
overageCurrency!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
43
src/modules/billing-usage/entities/stripe-event.entity.ts
Normal file
43
src/modules/billing-usage/entities/stripe-event.entity.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'stripe_events', schema: 'billing' })
|
||||
export class StripeEvent {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'stripe_event_id', type: 'varchar', length: 255, unique: true })
|
||||
@Index()
|
||||
stripeEventId!: string;
|
||||
|
||||
@Column({ name: 'event_type', type: 'varchar', length: 100 })
|
||||
@Index()
|
||||
eventType!: string;
|
||||
|
||||
@Column({ name: 'api_version', type: 'varchar', length: 20, nullable: true })
|
||||
apiVersion?: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
data!: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
@Index()
|
||||
processed!: boolean;
|
||||
|
||||
@Column({ name: 'processed_at', type: 'timestamptz', nullable: true })
|
||||
processedAt?: Date;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage?: string;
|
||||
|
||||
@Column({ name: 'retry_count', type: 'integer', default: 0 })
|
||||
retryCount!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
292
src/modules/partners/__tests__/partners.controller.test.ts
Normal file
292
src/modules/partners/__tests__/partners.controller.test.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { createMockPartner } from '../../../__tests__/helpers.js';
|
||||
import { AuthenticatedRequest } from '../../../shared/types/index.js';
|
||||
|
||||
// Mock the service
|
||||
const mockFindAll = jest.fn();
|
||||
const mockFindById = jest.fn();
|
||||
const mockCreate = jest.fn();
|
||||
const mockUpdate = jest.fn();
|
||||
const mockDelete = jest.fn();
|
||||
const mockFindCustomers = jest.fn();
|
||||
const mockFindSuppliers = jest.fn();
|
||||
|
||||
jest.mock('../partners.service.js', () => ({
|
||||
partnersService: {
|
||||
findAll: (...args: any[]) => mockFindAll(...args),
|
||||
findById: (...args: any[]) => mockFindById(...args),
|
||||
create: (...args: any[]) => mockCreate(...args),
|
||||
update: (...args: any[]) => mockUpdate(...args),
|
||||
delete: (...args: any[]) => mockDelete(...args),
|
||||
findCustomers: (...args: any[]) => mockFindCustomers(...args),
|
||||
findSuppliers: (...args: any[]) => mockFindSuppliers(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { partnersController } from '../partners.controller.js';
|
||||
|
||||
describe('PartnersController', () => {
|
||||
let mockReq: Partial<AuthenticatedRequest>;
|
||||
let mockRes: Partial<Response>;
|
||||
let mockNext: NextFunction;
|
||||
const tenantId = 'test-tenant-uuid';
|
||||
const userId = 'test-user-uuid';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReq = {
|
||||
user: {
|
||||
id: userId,
|
||||
userId,
|
||||
tenantId,
|
||||
email: 'test@test.com',
|
||||
role: 'admin',
|
||||
} as any,
|
||||
params: {},
|
||||
query: {},
|
||||
body: {},
|
||||
};
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis() as any,
|
||||
json: jest.fn() as any,
|
||||
};
|
||||
mockNext = jest.fn();
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated partners', async () => {
|
||||
const mockPartners = {
|
||||
data: [createMockPartner()],
|
||||
total: 1,
|
||||
};
|
||||
mockFindAll.mockResolvedValue(mockPartners);
|
||||
mockReq.query = { page: '1', limit: '20' };
|
||||
|
||||
await partnersController.findAll(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockFindAll).toHaveBeenCalledWith(tenantId, expect.objectContaining({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
}));
|
||||
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: true,
|
||||
data: mockPartners.data,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should apply filters from query params', async () => {
|
||||
mockFindAll.mockResolvedValue({ data: [], total: 0 });
|
||||
mockReq.query = { search: 'test', partnerType: 'customer', isActive: 'true' };
|
||||
|
||||
await partnersController.findAll(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockFindAll).toHaveBeenCalledWith(tenantId, expect.objectContaining({
|
||||
search: 'test',
|
||||
partnerType: 'customer',
|
||||
isActive: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should call next with error on invalid query', async () => {
|
||||
mockReq.query = { page: 'invalid' };
|
||||
|
||||
await partnersController.findAll(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return partner when found', async () => {
|
||||
const mockPartner = createMockPartner();
|
||||
mockFindById.mockResolvedValue(mockPartner);
|
||||
mockReq.params = { id: 'partner-uuid-1' };
|
||||
|
||||
await partnersController.findById(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockFindById).toHaveBeenCalledWith('partner-uuid-1', tenantId);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: true,
|
||||
data: mockPartner,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should call next with error when partner not found', async () => {
|
||||
mockFindById.mockRejectedValue(new Error('Not found'));
|
||||
mockReq.params = { id: 'nonexistent' };
|
||||
|
||||
await partnersController.findById(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create partner successfully', async () => {
|
||||
const mockPartner = createMockPartner();
|
||||
mockCreate.mockResolvedValue(mockPartner);
|
||||
mockReq.body = {
|
||||
code: 'PART-001',
|
||||
displayName: 'New Partner',
|
||||
email: 'new@partner.com',
|
||||
partnerType: 'customer',
|
||||
};
|
||||
|
||||
await partnersController.create(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'PART-001',
|
||||
displayName: 'New Partner',
|
||||
email: 'new@partner.com',
|
||||
partnerType: 'customer',
|
||||
}),
|
||||
tenantId,
|
||||
userId
|
||||
);
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
mockReq.body = {}; // Missing required code
|
||||
|
||||
await partnersController.create(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept snake_case fields', async () => {
|
||||
const mockPartner = createMockPartner();
|
||||
mockCreate.mockResolvedValue(mockPartner);
|
||||
mockReq.body = {
|
||||
code: 'PART-002',
|
||||
display_name: 'Snake Case Partner',
|
||||
partner_type: 'supplier',
|
||||
};
|
||||
|
||||
await partnersController.create(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update partner successfully', async () => {
|
||||
const mockPartner = createMockPartner({ displayName: 'Updated Name' });
|
||||
mockUpdate.mockResolvedValue(mockPartner);
|
||||
mockReq.params = { id: 'partner-uuid-1' };
|
||||
mockReq.body = { displayName: 'Updated Name' };
|
||||
|
||||
await partnersController.update(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'partner-uuid-1',
|
||||
expect.objectContaining({ displayName: 'Updated Name' }),
|
||||
tenantId,
|
||||
userId
|
||||
);
|
||||
});
|
||||
|
||||
it('should call next with error on invalid data', async () => {
|
||||
mockReq.params = { id: 'partner-uuid-1' };
|
||||
mockReq.body = { email: 'not-an-email' };
|
||||
|
||||
await partnersController.update(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete partner successfully', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
mockReq.params = { id: 'partner-uuid-1' };
|
||||
|
||||
await partnersController.delete(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('partner-uuid-1', tenantId, userId);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: true,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCustomers', () => {
|
||||
it('should return only customers', async () => {
|
||||
const mockCustomers = { data: [createMockPartner({ partnerType: 'customer' })], total: 1 };
|
||||
mockFindCustomers.mockResolvedValue(mockCustomers);
|
||||
mockReq.query = {};
|
||||
|
||||
await partnersController.findCustomers(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockFindCustomers).toHaveBeenCalledWith(tenantId, expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSuppliers', () => {
|
||||
it('should return only suppliers', async () => {
|
||||
const mockSuppliers = { data: [createMockPartner({ partnerType: 'supplier' })], total: 1 };
|
||||
mockFindSuppliers.mockResolvedValue(mockSuppliers);
|
||||
mockReq.query = {};
|
||||
|
||||
await partnersController.findSuppliers(
|
||||
mockReq as AuthenticatedRequest,
|
||||
mockRes as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockFindSuppliers).toHaveBeenCalledWith(tenantId, expect.any(Object));
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -12,6 +12,17 @@ jest.mock('../../../config/database.js', () => ({
|
||||
getClient: () => mockGetClient(),
|
||||
}));
|
||||
|
||||
// Mock sequences service
|
||||
const mockGetNextNumber = jest.fn();
|
||||
jest.mock('../../core/sequences.service.js', () => ({
|
||||
sequencesService: {
|
||||
getNextNumber: (...args: any[]) => mockGetNextNumber(...args),
|
||||
},
|
||||
SEQUENCE_CODES: {
|
||||
PURCHASE_ORDER: 'PO',
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { purchasesService } from '../purchases.service.js';
|
||||
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
|
||||
@ -28,6 +39,7 @@ describe('PurchasesService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetClient.mockResolvedValue(mockClient);
|
||||
mockGetNextNumber.mockResolvedValue('PO-00001');
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
@ -278,18 +290,29 @@ describe('PurchasesService', () => {
|
||||
it('should confirm draft order with lines', async () => {
|
||||
const draftOrder = createMockPurchaseOrder({
|
||||
status: 'draft',
|
||||
company_id: 'company-uuid',
|
||||
lines: [createMockPurchaseOrderLine()],
|
||||
});
|
||||
|
||||
mockQueryOne.mockResolvedValue(draftOrder);
|
||||
mockQuery.mockResolvedValue([createMockPurchaseOrderLine()]);
|
||||
mockQuery
|
||||
.mockResolvedValueOnce([createMockPurchaseOrderLine()]) // findById lines
|
||||
.mockResolvedValueOnce(undefined); // UPDATE status
|
||||
|
||||
// Mock client.query calls for confirm flow
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN returns empty
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'supplier-loc-uuid' }] }) // supplier location
|
||||
.mockResolvedValueOnce({ rows: [{ location_id: 'internal-loc-uuid', warehouse_id: 'wh-uuid' }] }) // internal location
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) // INSERT picking
|
||||
.mockResolvedValueOnce({ rows: [] }) // INSERT stock_move
|
||||
.mockResolvedValueOnce({ rows: [] }) // UPDATE status
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await purchasesService.confirm('po-uuid-1', tenantId, userId);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining("status = 'confirmed'"),
|
||||
expect.any(Array)
|
||||
);
|
||||
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
|
||||
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
|
||||
});
|
||||
|
||||
it('should throw ConflictError when order is not draft', async () => {
|
||||
|
||||
@ -30,6 +30,16 @@ jest.mock('../../core/sequences.service.js', () => ({
|
||||
},
|
||||
SEQUENCE_CODES: {
|
||||
SALES_ORDER: 'SO',
|
||||
PICKING_OUT: 'WH/OUT',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock stockReservationService
|
||||
jest.mock('../../inventory/stock-reservation.service.js', () => ({
|
||||
stockReservationService: {
|
||||
reserveWithClient: jest.fn(() => Promise.resolve({ success: true, errors: [] })),
|
||||
releaseWithClient: jest.fn(() => Promise.resolve()),
|
||||
checkAvailability: jest.fn(() => Promise.resolve({ available: true, lines: [] })),
|
||||
},
|
||||
}));
|
||||
|
||||
@ -364,15 +374,24 @@ describe('OrdersService', () => {
|
||||
it('should confirm draft order with lines', async () => {
|
||||
const order = createMockSalesOrder({
|
||||
status: 'draft',
|
||||
company_id: 'company-uuid',
|
||||
lines: [createMockSalesOrderLine()],
|
||||
});
|
||||
|
||||
mockQueryOne.mockResolvedValue(order);
|
||||
mockQuery.mockResolvedValue([createMockSalesOrderLine()]);
|
||||
mockQuery
|
||||
.mockResolvedValueOnce([createMockSalesOrderLine()]) // findById lines
|
||||
.mockResolvedValueOnce(undefined); // UPDATE status
|
||||
|
||||
// Mock client.query calls for confirm flow
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce(undefined) // BEGIN
|
||||
.mockResolvedValueOnce(undefined) // UPDATE status
|
||||
.mockResolvedValueOnce(undefined); // COMMIT
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ location_id: 'stock-loc-uuid', warehouse_id: 'wh-uuid' }] }) // stock location
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'customer-loc-uuid' }] }) // customer location
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) // INSERT picking
|
||||
.mockResolvedValueOnce({ rows: [] }) // INSERT stock_move
|
||||
.mockResolvedValueOnce({ rows: [] }) // UPDATE status
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
const result = await ordersService.confirm('order-uuid-1', tenantId, userId);
|
||||
|
||||
@ -403,14 +422,17 @@ describe('OrdersService', () => {
|
||||
it('should rollback on error', async () => {
|
||||
const order = createMockSalesOrder({
|
||||
status: 'draft',
|
||||
company_id: 'company-uuid',
|
||||
lines: [createMockSalesOrderLine()],
|
||||
});
|
||||
|
||||
mockQueryOne.mockResolvedValue(order);
|
||||
mockQuery.mockResolvedValue([createMockSalesOrderLine()]);
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce(undefined) // BEGIN
|
||||
.mockRejectedValueOnce(new Error('DB Error')); // UPDATE fails
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ location_id: 'stock-loc-uuid', warehouse_id: 'wh-uuid' }] }) // stock location
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'customer-loc-uuid' }] }) // customer location
|
||||
.mockRejectedValueOnce(new Error('DB Error')); // INSERT picking fails
|
||||
|
||||
await expect(
|
||||
ordersService.confirm('order-uuid-1', tenantId, userId)
|
||||
@ -422,17 +444,36 @@ describe('OrdersService', () => {
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
const mockClientCancel = {
|
||||
query: jest.fn(),
|
||||
release: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetClient.mockResolvedValue(mockClientCancel);
|
||||
});
|
||||
|
||||
it('should cancel draft order', async () => {
|
||||
const draftOrder = createMockSalesOrder({ status: 'draft' });
|
||||
mockQueryOne.mockResolvedValue(draftOrder);
|
||||
const draftOrder = createMockSalesOrder({
|
||||
status: 'draft',
|
||||
delivery_status: 'pending',
|
||||
invoice_status: 'pending',
|
||||
});
|
||||
mockQueryOne
|
||||
.mockResolvedValueOnce(draftOrder) // findById
|
||||
.mockResolvedValueOnce({ ...draftOrder, status: 'cancelled' }); // findById after cancel
|
||||
mockQuery.mockResolvedValue([]);
|
||||
|
||||
// Mock client.query for cancel flow (draft orders don't need stock release)
|
||||
mockClientCancel.query
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // UPDATE status
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await ordersService.cancel('order-uuid-1', tenantId, userId);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining("status = 'cancelled'"),
|
||||
expect.any(Array)
|
||||
);
|
||||
expect(mockClientCancel.query).toHaveBeenCalledWith('BEGIN');
|
||||
expect(mockClientCancel.query).toHaveBeenCalledWith('COMMIT');
|
||||
});
|
||||
|
||||
it('should throw ValidationError when order is done', async () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user